Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions pytest/modules/Speech_Recognition/test_reference_lyrics_aligner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
parse_lrc_synced_lyrics,
_compute_note_for_word,
_split_word_at_pitch_changes,
_trim_word_to_voiced,
create_midi_segments_from_reference_lyrics,
align_lyrics_to_audio,
)
Expand Down Expand Up @@ -196,6 +197,51 @@ def test_boundaries_preserved(self):
assert result[-1].end == pytest.approx(4.5)


# ---------------------------------------------------------------------------
# Voiced-region trimming
# ---------------------------------------------------------------------------


class TestTrimWordToVoiced:
"""Tests for _trim_word_to_voiced."""

def _word_with_trailing_silence(self):
"""A word whose second half is silence, so trimming kicks in."""
fps = 62.5
n_frames = int(4.0 * fps)
times = [i / fps for i in range(n_frames)]
freqs, confs = [], []
for t in times:
if 1.0 <= t <= 2.0:
freqs.append(440.0)
confs.append(0.95)
else:
freqs.append(0.0)
confs.append(0.0)
pd = PitchedData(times=times, frequencies=freqs, confidence=confs)
# CTC stretched the word until 3.5s although singing ends at 2.0s
word = {"word": "night", "start": 1.0, "end": 3.5,
"backing": True, "line_end": True}
return word, pd

def test_trims_trailing_silence(self):
word, pd = self._word_with_trailing_silence()
trimmed = _trim_word_to_voiced(word, pd)
assert trimmed["end"] < word["end"]

def test_preserves_flags_when_trimming(self):
"""Regression: trimming used to rebuild the dict and drop the
line_end/backing flags. CTC stretches precisely the last word of
each LRC line, so this silently killed nearly every LRCLIB
linebreak in the output file."""
word, pd = self._word_with_trailing_silence()
trimmed = _trim_word_to_voiced(word, pd)
assert trimmed["end"] < word["end"] # trimming actually happened
assert trimmed["line_end"] is True
assert trimmed["backing"] is True
assert trimmed["word"] == "night"


# ---------------------------------------------------------------------------
# Integration (with mocked WhisperX)
# ---------------------------------------------------------------------------
Expand Down
130 changes: 130 additions & 0 deletions pytest/modules/UltraStar/test_ultrastar_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
create_ultrastar_txt, silence_threshold, calculate_silent_beat_length,
format_separated_string, add_score_to_ultrastar_txt,
add_game_score_to_ultrastar_txt,
_compute_linebreak_indices, _enforce_max_line_length,
)
from src.modules.Midi.MidiSegment import MidiSegment
from src.modules.Ultrastar.ultrastar_txt import UltrastarTxtValue, UltrastarTxtTag
Expand Down Expand Up @@ -569,5 +570,134 @@ def test_add_score_to_ultrastar_txt_still_works(self):
os.remove(path)


def _seg(word, start, end, line_break_after=False):
return MidiSegment("A4", start, end, word,
line_break_after=line_break_after)


def _make_line(words, start=0.0, note_s=0.4, gap_s=0.05):
"""Build consecutive word segments with a uniform small gap."""
segments = []
t = start
for w in words:
segments.append(_seg(w + " ", t, t + note_s))
t += note_s + gap_s
return segments


class TestComputeLinebreakIndices(unittest.TestCase):
def test_lrclib_flags_win_over_silence(self):
segments = _make_line(["one", "two", "three", "four"])
segments[1].line_break_after = True
breaks = _compute_linebreak_indices(segments, silence_split_duration=0.01)
self.assertEqual(breaks, {1})

def test_lrclib_flag_on_last_segment_is_ignored(self):
segments = _make_line(["one", "two"])
segments[-1].line_break_after = True
breaks = _compute_linebreak_indices(segments, silence_split_duration=None)
self.assertEqual(breaks, set())

def test_silence_fallback_breaks_at_long_gaps(self):
segments = _make_line(["one", "two", "three"])
# Insert a much longer gap after "two"
long_gap = 2.0
offset = long_gap - 0.05
for s in segments[2:]:
s.start += offset
s.end += offset
breaks = _compute_linebreak_indices(segments, silence_split_duration=1.0)
self.assertEqual(breaks, {1})

def test_no_threshold_no_fallback_breaks(self):
segments = _make_line(["one", "two", "three"])
breaks = _compute_linebreak_indices(segments, silence_split_duration=None)
self.assertEqual(breaks, set())


class TestEnforceMaxLineLength(unittest.TestCase):
def test_short_line_untouched(self):
segments = _make_line(["short", "line"])
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=45)
self.assertEqual(breaks, set())

def test_overlong_line_splits_at_largest_gap(self):
words = ["alpha", "bravo", "charlie", "delta", "echo",
"foxtrot", "golf", "hotel"]
segments = _make_line(words)
# Clear pause after "delta" (index 3)
offset = 1.0
for s in segments[4:]:
s.start += offset
s.end += offset
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=30)
self.assertIn(3, breaks)

def test_overlong_line_without_gaps_splits_near_middle(self):
words = ["aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"]
segments = []
t = 0.0
for w in words:
segments.append(_seg(w + " ", t, t + 0.4))
t += 0.4 # zero gap between notes
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=20)
self.assertTrue(breaks)
# All resulting lines must respect the limit (rendered length =
# raw concatenation of the note texts, trailing space stripped)
idx = sorted(breaks) + [len(segments) - 1]
start = 0
for end in idx:
chars = len("".join(s.word for s in segments[start:end + 1]).rstrip())
self.assertLessEqual(chars, 20)
start = end + 1

def test_syllable_notes_are_not_overcounted(self):
"""Syllable notes without trailing spaces attach directly in the
rendered line - they must not be counted as separate words.
'Sunshine holiday' rendered is 16 chars, far under the limit."""
segments = [
_seg("Sun", 0.0, 0.3),
_seg("shine ", 0.4, 0.7),
_seg("ho", 0.8, 1.1),
_seg("li", 1.2, 1.5),
_seg("day", 1.6, 1.9),
]
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=17)
self.assertEqual(breaks, set())

def test_never_breaks_inside_melisma(self):
"""Continuation notes (no trailing space) are not split points."""
segments = [
_seg("looooooooooooong ", 0.0, 0.4),
_seg("~", 0.5, 0.9),
_seg("~", 1.0, 1.4),
_seg("~", 1.5, 1.9),
_seg("meloooooooooody ", 2.0, 2.4),
_seg("here", 2.5, 2.9),
]
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=15)
for b in breaks:
self.assertTrue(segments[b].word.endswith(" "))

def test_nested_split_handles_very_long_lines(self):
words = ["word%02d" % i for i in range(20)]
segments = _make_line(words)
breaks = set()
_enforce_max_line_length(segments, breaks, max_chars=25)
self.assertGreaterEqual(len(breaks), 3)

def test_existing_breaks_respected(self):
"""Lines already short enough through existing breaks stay as-is."""
segments = _make_line(["one", "two", "three", "four"])
breaks = {1}
_enforce_max_line_length(segments, breaks, max_chars=45)
self.assertEqual(breaks, {1})


if __name__ == "__main__":
unittest.main()
7 changes: 6 additions & 1 deletion src/modules/Speech_Recognition/reference_lyrics_aligner.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,12 @@ def _trim_word_to_voiced(
if new_end - new_start < 0.05:
return word

return {"word": word["word"], "start": new_start, "end": new_end}
# Preserve ALL other keys (backing, line_end, ...): CTC stretches
# precisely the LAST word of each LRC line into the following pause,
# so almost every line-end word lands here - rebuilding the dict from
# scratch used to silently drop the line_end flag, which killed nearly
# all LRCLIB linebreaks in the output file.
return {**word, "start": new_start, "end": new_end}


def _split_word_at_silence_gaps(
Expand Down
Loading
Loading