notes: keep matched words that fall inside a missing-lyrics span

A missing span covers whole Whisper segments, so it often also holds lines
that were heard and matched. Skipping every aligned word starting inside one
dropped those lines' notes and left empty line breaks in the chart. Words
from lines never heard are already excluded via dropped_lines, so the span
now only drives the warning and the review marker.
This commit is contained in:
2026-10-09 16:52:56 -07:00
parent 62b6cd3e80
commit 94bfa56d08
4 changed files with 14 additions and 36 deletions
+2 -10
View File
@@ -84,19 +84,11 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None,
) -> tuple[list[TimedNote], list[float], list[str]]:
notes: list[TimedNote] = []
breaks: list[float] = []
prev_line, prev_end, last_seen_line = None, 0.0, None
prev_line, prev_end = None, 0.0
for row in rows:
# last_seen_line tracks every row (skipped or not) so a run of
# skipped rows can't trigger a duplicate break on the next row of
# the same still-unseen line; prev_line/prev_end only advance on
# rows that actually emit notes, so a skipped row can't corrupt
# line_initial or break timing for its own line's later words.
if last_seen_line is not None and row["line_index"] > last_seen_line:
if prev_line is not None and row["line_index"] > prev_line:
breaks.append((prev_end + row["start"]) / 2)
last_seen_line = row["line_index"]
line_initial = row["line_index"] != prev_line
if any(a <= row["start"] < b for a, b in missing_spans):
continue
prev_line, prev_end = row["line_index"], row["end"]
segs = segment_span(times, f0, conf, row["start"], row["end"])
if not segs:
+2 -3
View File
@@ -47,9 +47,8 @@ def classify_lyrics_match(segments, ref: RefLyrics, pad: float = 1.0
dropped_lines: reference line indices covered only by 'insert' opcodes
(present in the lyrics, never heard). missing_spans: the padded, merged
bounds of the heard segments behind 'delete' opcodes (heard, but matching
no reference word) — scoped tightly to the unmatched audio itself, not
the whole vocal section it falls in, so matched lines sharing a section
with an ad-lib keep their notes.
no reference word). A segment can also hold matched lines, so a span
marks where to look, not words to discard.
"""
matcher, seg_of, line_of = match_lyrics(segments, ref)
covered = [False] * len(line_of)
+9 -22
View File
@@ -297,15 +297,19 @@ def test_notes_stage_registered():
assert "notes" in [s.name for s in all_stages()]
def test_build_notes_skips_notes_within_missing_span():
def test_build_notes_keeps_matched_words_within_missing_span():
# A missing span covers whole Whisper segments, so it can hold matched
# lines alongside the unmatched singing; those lines must keep their notes.
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "skip", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
{"word": "held", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(4.5, 6.0)])
assert [n.text for n in notes] == ["keep"]
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English",
missing_spans=[(4.5, 6.0)])
assert [n.text for n in notes] == ["keep", "held"]
assert breaks == [(1.4 + 5.0) / 2]
assert warnings == ["missing lyrics near 0:04–0:06"]
def test_warnings_report_dropped_lines():
@@ -325,23 +329,6 @@ def test_warnings_report_missing_spans():
assert warnings == ["missing lyrics near 1:21–1:29"]
def test_build_notes_skipped_row_mid_line_does_not_corrupt_spacing_or_breaks():
# "gone" falls inside a missing span and is skipped; "away" is the same
# line's next word and must still get its leading space, and the break
# before the next line must use the last *emitted* word's end (3.4, from
# "away"), not the skipped row's end (2.4).
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "gone", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 0},
{"word": "away", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 0},
{"word": "next", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, breaks, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(1.9, 2.5)])
assert [n.text for n in notes] == ["keep", " away", "next"]
assert breaks == [(3.4 + 5.0) / 2]
def test_notes_carry_line_index():
times, f0, conf = flat_pitch()
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
+1 -1
View File
@@ -53,7 +53,7 @@ def test_classify_lyrics_match_keeps_partially_matched_lines():
def test_classify_lyrics_match_scopes_missing_span_to_adlib_not_whole_section():
# matched line and an unmatched ad-lib close enough together that
# vocal_sections would merge them into one section; missing_spans must
# stay tight around the ad-lib so the matched line keeps its notes.
# stay tight around the ad-lib so the review marks the right place.
ref = normalize_lyrics("she's got a smile\nthat it seems to me")
segs = [seg(1, 4, "she's got a smile that it seems to me"),
seg(6, 8, "completely unrelated ad lib")]