Merge pull request 'Keep matched words that fall inside a missing-lyrics span' (#13) from missing-notes into main

Reviewed-on: #13
Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #13.
This commit is contained in:
2026-10-09 16:54:17 -07:00
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")]