Skip to content

Make transcript span-normalization O(edit) instead of O(transcript): scoped dirty-set scan + incremental wordArr splice #398

Description

@maboa

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 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
12.6 ms 36.9 ms
17.7 ms 51.6 ms
10× (low-end phone) 30 ms 77 ms

Where the with-edit cost lives (8,000 words):

Operation
Full wordArr rebuild (setupTranscriptWordscreateWordArray) 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

  1. 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.
  2. 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).
  3. 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.
  4. 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.

Follow-up to #394.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions