transcribe: classify reference lines as matched/dropped, flag unmatched singing

This commit is contained in:
2026-07-27 22:58:58 -07:00
parent 5bc4855264
commit cf1c7410ab
2 changed files with 76 additions and 4 deletions
+49 -3
View File
@@ -20,10 +20,56 @@ def _tokens(text: str) -> list[str]:
return [t for w in text.split() if (t := alignment_token(w))]
def match_lyrics(segments, ref: RefLyrics):
heard, seg_of = [], []
for si, s in enumerate(segments):
for t in _tokens(s.text):
heard.append(t)
seg_of.append(si)
pairs = [(t, li) for w, li in zip(ref.words, ref.word_line_index)
if (t := alignment_token(w))]
expected = [t for t, _ in pairs]
line_of = [li for _, li in pairs]
matcher = difflib.SequenceMatcher(a=heard, b=expected)
return matcher, seg_of, line_of
def lyrics_similarity(segments, ref: RefLyrics) -> float:
heard = [t for s in segments for t in _tokens(s.text)]
expected = [t for w in ref.words if (t := alignment_token(w))]
return difflib.SequenceMatcher(a=heard, b=expected).ratio()
matcher, _, _ = match_lyrics(segments, ref)
return matcher.ratio()
def classify_lyrics_match(segments, ref: RefLyrics, sections
) -> tuple[list[int], list[tuple[float, float]]]:
"""Split reference lines into matched vs. dropped, and flag sung spans no
reference line explains.
dropped_lines: reference line indices covered only by 'insert' opcodes
(present in the lyrics, never heard). missing_spans: vocal `sections`
containing 'delete' opcodes (heard, but matching no reference word).
"""
matcher, seg_of, line_of = match_lyrics(segments, ref)
covered = [False] * len(line_of)
for tag, _, _, bj1, bj2 in matcher.get_opcodes():
if tag in ("equal", "replace"):
for j in range(bj1, bj2):
covered[j] = True
by_line = {}
for j, li in enumerate(line_of):
by_line.setdefault(li, []).append(covered[j])
dropped_lines = sorted(li for li, cov in by_line.items() if not any(cov))
missing = set()
for tag, ai1, ai2, _, _ in matcher.get_opcodes():
if tag != "delete":
continue
si = seg_of[ai1]
mid = (segments[si].start + segments[si].end) / 2
for a, b in sections:
if a <= mid <= b:
missing.add((a, b))
break
return dropped_lines, sorted(missing)
def _run(ctx: Ctx) -> None:
+27 -1
View File
@@ -1,6 +1,6 @@
from songtrace.lyrics import normalize_lyrics
from songtrace.whisper_client import TranscriptSegment
from songtrace.stages.transcribe import lyrics_similarity, vocal_sections
from songtrace.stages.transcribe import classify_lyrics_match, lyrics_similarity, vocal_sections
def seg(s, e, t=""):
return TranscriptSegment(start=s, end=e, text=t)
@@ -26,3 +26,29 @@ def test_similarity_low_for_wrong_lyrics():
ref = normalize_lyrics("completely different words here entirely")
segs = [seg(0, 2, "it's one two three four five six seven eight")]
assert lyrics_similarity(segs, ref) < 0.4
def test_classify_lyrics_match_flags_extra_reference_line():
ref = normalize_lyrics("it's one two three\nfour five six seven eight\nnine ten eleven")
segs = [seg(1, 4, "it's one two three four five six seven eight")]
sections = vocal_sections(segs)
dropped, missing = classify_lyrics_match(segs, ref, sections)
assert dropped == [2]
assert missing == []
def test_classify_lyrics_match_flags_unmatched_singing():
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
segs = [seg(1, 4, "it's one two three four five six seven eight"),
seg(20, 23, "completely unrelated ad lib bridge")]
sections = vocal_sections(segs)
dropped, missing = classify_lyrics_match(segs, ref, sections)
assert dropped == []
assert missing == [(19.0, 24.0)]
def test_classify_lyrics_match_keeps_partially_matched_lines():
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
# line 1 heard with one word garbled ("five" -> "fife"): still a replace/equal
# match, so the line stays matched rather than dropped
segs = [seg(1, 4, "it's one two three four fife six seven eight")]
sections = vocal_sections(segs)
dropped, _ = classify_lyrics_match(segs, ref, sections)
assert dropped == []