Line references are pinned to main at afbd7c45.
Pre-existing on main. Not introduced by #373 or #374 — #374 moved two module-level refs into instance fields on LyricsSession without changing the logic, so do not bisect to it. Pre-split proof below.
Where
src/lib/lyrics-session/lyrics-session.ts L251-266 and L355-358:
// L251-266
syncActiveWord(adjustedMs: number): void {
const { lines, activeLineIndex } = this.getState();
const words = lines[activeLineIndex]?.words;
if (words && words.length > 0) {
const index = findActiveWordIndex(words, adjustedMs);
if (index === this.syncedWordIndex) return; // <- stale latch
this.syncedWordIndex = index;
this.setActiveWordIndex(index);
return;
}
if (this.syncedWordIndex === -1) return;
this.syncedWordIndex = -1;
this.setActiveWordIndex(-1);
}
// L355-358
private setActiveLineIndex(index: number): void {
if (index === this.getState().activeLineIndex) return;
this.set({ activeLineIndex: index, activeWordIndex: -1 }); // <- store reset, latch not
}
Both are driven every frame from src/lib/lyrics-engine.ts L352-353.
What goes wrong
setActiveLineIndex writes activeWordIndex: -1 into the store on every line change but leaves this.syncedWordIndex holding the value derived for the previous line. The store and the latch disagree from that moment on.
On the next frame syncActiveWord computes the word index for the new line. If that value equals the leftover latch, the guard at L257 returns early, setActiveWordIndex is never called, and the store keeps activeWordIndex: -1. The word renders unhighlighted until the singer reaches a word whose index differs from the stale value.
Conditions
Timed (word-level) lyrics only; plain-text lines have no word indices. The trigger is a line change where the first active word index on the new line equals the last active word index on the old line. A line change normally lands on word 0, so any line whose last highlighted word was index 0 swallows the highlight on the line that follows it — a one-word line, or any line the playhead left before the second word.
The existing test at src/lib/lyrics-session/lyrics-session.test.ts L532-542 looks like it covers this and does not: it moves onto line(2000, "plain"), a line with no words, which takes the else branch at L263-265 and resets the latch to -1 as a side effect. A regression test needs two consecutive lines that both have words.
Pre-existing proof
Same split of responsibilities before #373/#374, in two files rather than one:
32ec36c6:src/stores/lyrics-store.tsL220-224 —setActiveLineIndexsets{ activeLineIndex: index, activeWordIndex: -1 }.32ec36c6:src/lib/lyrics-engine.tsL431-436 —prevActiveWordIndexRefis only written from the word branch, never on a line change.
Fix direction (my view, not the reviewer's)
The suggestion that surfaced this was to add this.syncedWordIndex = -1; inside setActiveLineIndex. That fixes the symptom, and if a minimal patch is wanted it is the right one — but it leaves the defect class in place.
I would delete the latches instead. syncedWordIndex and syncedLineIndex are redundant with the store they shadow: setActiveWordIndex (L361-364) and setActiveLineIndex (L355-358) already early-return when the incoming index equals the current store value, so the latch performs the identical comparison one layer up. The only reason the two can disagree is the bug in this issue.
Have syncActiveWord and syncActiveLine compare against getState().activeWordIndex / getState().activeLineIndex and drop both fields. resetActiveIndexLatches() (L268-272) can go with them: both of its callers, load (L131-144) and clear (L172-186), already set activeLineIndex: -1, activeWordIndex: -1 in the very next statement, so the reset is doing nothing the store write does not already do.
That removes the possibility of divergence by construction rather than patching the one site where it currently bites, and it deletes a field, a method and two call sites.
Not fixing here
Filed for tracking only.