You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The edit-time span normalization added in #394 (reflow/merge/split + word-index rebuild) currently runs a full O(N) pass over every [data-m] span on blur and on the debounced sanitise() pass. It's fast on a modern desktop but scales with transcript length and device speed. Make it O(edit) by (1) tracking the edited region and only normalizing that, and (2) patching the player's wordArr incrementally instead of rebuilding it.
Measurements
Synthetic 8,000-word transcript (~50 min of speech), headless Chromium, CPU throttled to emulate slower devices.
Full normalization pass (normalizeTranscriptSpans):
Device speed
Idle (no edit)
With one edit (triggers re-index)
1× (fast Mac)
3.3 ms
8.6 ms
4×
12.6 ms
36.9 ms
6×
17.7 ms
51.6 ms
10× (low-end phone)
30 ms
77 ms
Where the with-edit cost lives (8,000 words):
Operation
1×
6×
Full wordArr rebuild (setupTranscriptWords → createWordArray)
2.7 ms
18.6 ms
Visual refresh (updateTranscriptVisualState)
~0 ms
0.1 ms
splice insert+remove mid-array (8,000 elems)
~0 ms
~0 ms
Findings
The idle pass isn't free — every blur/debounce re-scans all spans even when nothing changed (3–30 ms depending on device). A dirty check should make it ~0.
The entire re-index cost is createWordArray re-walking the DOM (a parentNode walk + allocation per word). The visual refresh is already a cheap delta update.
wordArr is a contiguous array, so a mid-array splice is O(N) — but that shift is a native pointer memmove and measures ~0 ms even at 8,000 elements / 6×. The array-vs-linked-list distinction is irrelevant to perf here; the cost is the DOM traversal, not the array mechanics. So incremental splice (~0 ms) vs full rebuild (18.6 ms @ 6×) is a large, real win.
Normalization does not run on the seek path today, so it doesn't directly delay a seek. But a future "normalize on seek" must be deferred (queueMicrotask/setTimeout(0)) so it never blocks the seek — a synchronous pass would cost 50–77 ms on a slow device + long transcript.
Proposed change
Track the edited region. On input (covers typing, paste, cut, IME), add the caret's containing [data-m] span plus its previous and next sibling to a dirty Set (a join glues to the next span; a leaked letter lands in the previous). Push is O(1); the Set dedups.
Scoped scan.reflowLeakedFragments / mergeJoinedSpans / split iterate the dirty set (+ neighbours) instead of querySelectorAll('[data-m]'), then clear it. Empty set → return immediately (subsumes a dirty flag; idle → ~0 ms).
Incremental wordArr. On split (1→2) / merge (2→1), splice the changed entries in/out (find index via a node indexOf — also ~0 ms) instead of setupTranscriptWords(). Keep the cheap updateTranscriptVisualState refresh.
Net: normalization becomes O(edit), sub-millisecond, independent of transcript length and device.
Risks / notes
Incremental patching couples us to the library's wordArr entry shape { n, m, p } — but we already depend on it in the Karaoke highlight crashes mid-edit on null parentNode #294 patch and sanitise(), so it's not new exposure. js/hyperaudio-lite.js stays untouched (we only manipulate the instance's array).
The span-merge e2e tests currently poke textContent directly and dispatch a bare blur; they'll need to drive edits through the dirty-tracking path.
Fallback: if the dirty set is ever unavailable (e.g. programmatic transcript load), fall back to the full pass so correctness never depends on tracking being perfect.
Summary
The edit-time span normalization added in #394 (reflow/merge/split + word-index rebuild) currently runs a full O(N) pass over every
[data-m]span on blur and on the debouncedsanitise()pass. It's fast on a modern desktop but scales with transcript length and device speed. Make it O(edit) by (1) tracking the edited region and only normalizing that, and (2) patching the player'swordArrincrementally instead of rebuilding it.Measurements
Synthetic 8,000-word transcript (~50 min of speech), headless Chromium, CPU throttled to emulate slower devices.
Full normalization pass (
normalizeTranscriptSpans):Where the with-edit cost lives (8,000 words):
wordArrrebuild (setupTranscriptWords→createWordArray)updateTranscriptVisualState)spliceinsert+remove mid-array (8,000 elems)Findings
createWordArrayre-walking the DOM (aparentNodewalk + allocation per word). The visual refresh is already a cheap delta update.wordArris a contiguous array, so a mid-arrayspliceis O(N) — but that shift is a native pointer memmove and measures ~0 ms even at 8,000 elements / 6×. The array-vs-linked-list distinction is irrelevant to perf here; the cost is the DOM traversal, not the array mechanics. So incremental splice (~0 ms) vs full rebuild (18.6 ms @ 6×) is a large, real win.queueMicrotask/setTimeout(0)) so it never blocks the seek — a synchronous pass would cost 50–77 ms on a slow device + long transcript.Proposed change
input(covers typing, paste, cut, IME), add the caret's containing[data-m]span plus its previous and next sibling to a dirtySet(a join glues to the next span; a leaked letter lands in the previous). Push is O(1); the Set dedups.reflowLeakedFragments/mergeJoinedSpans/ split iterate the dirty set (+ neighbours) instead ofquerySelectorAll('[data-m]'), then clear it. Empty set → return immediately (subsumes a dirty flag; idle → ~0 ms).wordArr. On split (1→2) / merge (2→1),splicethe changed entries in/out (find index via a nodeindexOf— also ~0 ms) instead ofsetupTranscriptWords(). Keep the cheapupdateTranscriptVisualStaterefresh.Risks / notes
wordArrentry shape{ n, m, p }— but we already depend on it in the Karaoke highlight crashes mid-edit on null parentNode #294 patch andsanitise(), so it's not new exposure.js/hyperaudio-lite.jsstays untouched (we only manipulate the instance's array).span-mergee2e tests currently poketextContentdirectly and dispatch a bareblur; they'll need to drive edits through the dirty-tracking path.Follow-up to #394.