feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)

* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)

Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): emit canonical tts-position event from TTSController (#3235)

Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)

RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.

ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)

useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): paragraph mode follows TTS playback (#3235)

When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): speed reader follows TTS playback (#3235)

Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)

Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): 'following audio' indicator for TTS sync (#3235)

5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)

Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)

Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(reader): translate TTS-sync strings across 33 locales (#3235)

Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)

RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)

Two read-along refinements (verified live via CDP with Edge TTS):

1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
   BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
   estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
   snapped it back. Now once a word position is seen, sentence positions are
   ignored and any running estimator is stopped, so words alone drive RSVP.

2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
   play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
   own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
   stop releases it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)

- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
  pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
  WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
  Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
  the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
  handleManageDictionary now exits RSVP first (position saved/resumable) so
  management shows over the reader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)

Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)

The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)

Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)

Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).

- TTSController already tags tts-position with kind word|sentence. The hook
  decides granularity: word boundaries (Edge) drive a per-word highlight; once
  seen, the coarse sentence event is skipped so the whole sentence doesn't
  flicker over the current word. Engines without word boundaries
  (WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
  clone's text) and tagged with the paragraph index so a stale highlight never
  paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.

Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-14 15:46:18 +08:00
committed by GitHub
parent cc618b8739
commit bfb85c2f68
63 changed files with 4941 additions and 89 deletions
@@ -0,0 +1,367 @@
<!-- /autoplan restore point: /Users/chrox/.gstack/projects/readest/autoplan-restore-tts-sync-20260614-024952.md -->
# Sync paragraph mode & speed reader with TTS (#3235)
## Problem
Readest has three independent reading aids that don't talk to each other:
- **TTS** (read-aloud) — now emits per-**word** boundaries on Edge voices and per-**sentence**
marks on every engine, each tagged with a CFI.
- **Paragraph mode** — a focus overlay that shows one block at a time.
- **Speed reader (RSVP)** — flashes one word at a time, paced by its own WPM timer.
Issue #3235 asks that paragraph mode and the speed reader **sync with TTS** so you can
listen and follow along in either view.
## Locked decisions
1. **TTS is the clock.** The two visual modes become *viewers* of the spoken position;
they follow TTS, not the reverse.
2. **Both modes, one PR.** They share the same CFI-driven "follow TTS" plumbing.
3. **RSVP on non-Edge voices → sentence-paced fallback.** Word boundaries are Edge-only;
on sentence-only engines RSVP jumps to the spoken sentence's first word and self-paces
through its words, correcting at each sentence mark.
4. **Automatic coupling, but visible and escapable.** Whenever TTS and a mode are both
active they sync by default — no separate "sync mode" setting. A visible "following
audio" indicator shows when sync is active, and a manual scroll / paragraph-nav / RSVP
skip **decouples** until the user re-engages. (Refined per CEO review — see audit trail.)
5. **RSVP gets a minimal TTS play/pause icon** in its existing control row (RSVP is a
full-screen overlay that otherwise hides the TTS transport). This surfaces the existing
TTS transport; it is not a new sync toggle.
6. **RSVP speed control is disabled while TTS-driven**, not repurposed to TTS rate.
`handleSetRate` does a throttled (3000ms) stop→setRate→start cycle, so wiring a rapid
+/- stepper to it would stutter/gap the audio. While externally driven, pace comes from
the TTS voice (Edge word boundaries, or the non-Edge estimator); the WPM control is
greyed out. (Refined per CEO review — see audit trail.)
## Architecture
### Glue: CFI
Every TTS position is already reported as a CFI, and both modes already map a CFI back to
their own index:
- `ParagraphIterator.findByRangeAsync(range)` → paragraph index (`src/utils/paragraph.ts`)
- `RSVPController.findWordIndexByCfi(cfi)` → word index (`src/services/rsvp/RSVPController.ts`)
So syncing is mostly: deliver the TTS CFI to each mode and call its existing seek.
### A. The bridge — `tts-position` broadcast
**The `TTSController` emits the canonical event; the hook forwards.** (Corrected per Eng
review — both voices flagged hook-owned broadcast as critical.) The controller already owns
the source of truth (`#ttsSectionIndex`, the active word/sentence range, `getCurrentHighlightCfi`,
`reapplyCurrentHighlight`). It emits `tts-position` from the same code paths that already fire
`tts-highlight-word` / `tts-highlight-mark` (`dispatchSpeakWord` / `dispatchSpeakMark`):
```ts
// on the controller (one source of truth):
{ cfi, kind: 'word' | 'sentence', sectionIndex, sequence } // sequence = monotonic counter
```
`useTTSControl` adds `bookKey` and forwards it onto the shared `eventDispatcher` from a
**dedicated listener** (NOT inside `handleHighlightWord/Mark`, which early-return on
`followingTTSLocationRef` and active text selection — bolting the broadcast there would
silently desync the modes exactly when the reader suppresses page-follow). Forward gated only
by lifecycle, not page-follow state.
```ts
eventDispatcher.dispatch('tts-position', { bookKey, cfi, kind, sectionIndex, sequence });
```
**Latest-only / ordering.** `eventDispatcher.dispatch` awaits listeners serially and callers
fire-and-forget, so a slow word-42 map can land after word-43. Consumers keep
`lastSequenceSeen` and **ignore any event with a smaller `sequence`**; keep the hot path
synchronous (cursor lookup, no `await`/rAF inside the listener). Paragraph work coalesces to
sentence granularity.
**Lifecycle.** `ttsEnabled` is in `readerStore` (per `bookKey`) — modes read it. But
`isPlaying` is **hook-local** to `useTTSControl`, so add a `tts-playback-state` signal
(`{ bookKey, state: 'playing'|'paused'|'stopped' }`) the modes subscribe to for the
`following` ↔ paused transitions; do not assume RSVP can read `isPlaying`.
**Rejected alternatives:** putting the controller in a store/context (it is recreated each
TTS session → constant re-subscribe churn, and leaks the instance widely); polling
`getCurrentHighlightCfi()` (still needs controller access, plus a poll loop).
### B. Paragraph follows TTS — all engines
Sentence granularity is enough to choose a paragraph, so this works on every TTS engine.
In `useParagraphMode`, subscribe to `tts-position` (filtered by `bookKey`):
1. Resolve `cfi``Range` (`view.resolveCFI`).
2. If the CFI's section ≠ the iterator's section, the view has already been navigated by
TTS; let the existing relocate handler re-init the iterator, then map on the next event.
3. `iterator.findByRangeAsync(range)``index`. If `index` ≠ current focus,
`goToParagraph(index)` + `focusCurrentParagraph()`.
4. Guard with the existing `isFocusingRef` so the programmatic scroll doesn't re-enter.
**Start alignment.** When TTS is started while paragraph mode is active, dispatch
`tts-speak` with the focused paragraph's `range` and section `index` (the `tts-speak`
detail is `{ bookKey, range, index }`) so audio begins at the focused block (not the page
top). The focus flow already holds `currentRange`.
**Manual nav while synced.** When the user taps next/prev paragraph and TTS is playing,
**decouple** (stop following audio; the "following audio" indicator hides). TTS keeps
playing; the user is now browsing independently. Re-engaging the indicator re-syncs to the
current TTS position. (Decision 4 — decouple-on-manual-interaction, not reverse-driving.)
### C. Speed reader follows TTS
Add an **external-drive mode** to `RSVPController`: when engaged it suspends the internal
WPM `scheduleNextWord` timer and shows whatever word the driver points at.
New surface on `RSVPController`:
- `syncToCfi(cfi: string): void``findWordIndexByCfi``seekToIndex` (no timer arm).
- `setExternallyDriven(on: boolean): void` — suspend/resume the internal timer and flip a
flag the overlay reads to adjust controls.
Wiring in `RSVPControl` (subscribes to `tts-position`, filtered by `bookKey`):
- **Edge (`kind: 'word'`):** `controller.syncToCfi(cfi)` — exact word-for-word read-along.
- **Non-Edge (`kind: 'sentence'`):** jump to the sentence's first word
(`findWordIndexByCfi`), then advance its words on a local timer at an **estimated WPM
derived from the TTS voice rate** (`ESTIMATED_TTS_WPM ≈ 190 × viewSettings.ttsRate`, a
named tunable). The next sentence event snaps/corrects drift.
**Start alignment.** Starting TTS while RSVP is open dispatches `tts-speak` with the
current RSVP word's `range` and section `index`.
**Section transitions.** When TTS drives the view into the next section, RSVP re-extracts
words for the new section (reuse `loadNextPageContent`) on the relocate, then resumes
mapping CFIs. (Today RSVP-initiated section change goes through `rsvp-request-next-page`;
the TTS-driven path is the inverse — react to the view relocating.)
**Pace control while synced.** With the WPM timer suspended, the RSVP speed control is
**disabled / greyed out** while TTS-driven (decision 6) — do NOT repurpose it to TTS rate
(`handleSetRate`'s throttled stop→start would stutter the audio). Pace comes from the TTS
voice. To change speed while synced, the user adjusts the TTS rate from the TTS panel.
**Decouple on manual interaction.** A manual RSVP skip/seek (or paragraph next/prev) drops
the externally-driven state and returns the mode to its own controls; the "following audio"
indicator hides. Re-engaging (e.g. tapping the RSVP TTS icon, or the indicator) re-syncs to
the current TTS position.
### D. RSVP TTS trigger (decision 5)
Add one TTS play/pause icon to the RSVP overlay's control row. It dispatches the existing
`tts-speak` / `tts-stop` events (with the current RSVP word's range for start). State
(playing/idle) reflects `ttsEnabled`/`isPlaying`. Paragraph mode needs no new control —
its footer/TTS transport remains reachable.
## UI states & placement (from Design review — auto-adopted)
Both design voices flagged that the plan named widgets without designing them. Resolved
decisions below; build the indicator as one reusable component.
### Following-audio indicator — three states, not two
| State | When | Rendering |
|-------|------|-----------|
| `following` | TTS playing + mode following | Filled pill, voice/equalizer glyph + "Following audio". RSVP on non-Edge appends "· estimated" (sets expectation that pacing is approximate). |
| `decoupled` | TTS still playing, user took manual control | Ghost pill, sync glyph + "Resume audio" — **the pill IS the re-engage button**. Do NOT hide it (hiding reads as "sync broke"). |
| `syncing` | section re-init / CFI not yet mapped | `loading-dots` sub-state (reuse ParagraphBar's existing loading affordance). |
| `not-synced` | TTS off | Unmounted. |
| `unsupported` | fixed-layout if sync is gated off | No pill; one-line "Sync unavailable for this book" hint (don't fail silently). |
Component rules: `eink-bordered`, full-opacity text + glyph (never color-alone — RSVP is a
self-themed surface so global `[data-eink]` rules don't auto-apply; hand-apply, precedent =
RSVP "Look up" pill), logical start/end props (RTL), `touch-target`/`min-h-11` on mobile,
safe-area top inset via the overlay's `gridInsets`.
**Placement.** Paragraph mode → persistent top-center chip on `ParagraphOverlay` (NOT in the
auto-hiding `ParagraphBar`, which vanishes after 2s); relate it visually to the existing
"Back to TTS Location" affordance. RSVP → a slim status row **below the header, above the
context panel** — NOT inside the crowded transport row. Also resolve the paragraph-mode
bottom-bar stack: suppress `TTSBar` while paragraph mode is active (its transport duplicates
what the mode now follows).
### RSVP TTS toggle (decision 5)
Voice/headphones glyph (reuse `TTSIcon` / the TTS panel's voice metaphor) — **never a second
play triangle** (RSVP already has a 56px center play for word-flashing). `aria-label`
"Play audio" / "Pause audio" (distinct from RSVP's "Play"/"Pause"). Place at the control
cluster's trailing edge near the settings gear, divider-separated. Match RSVP's local idiom
(`gray-500/xx` ghost), not a daisyui `btn` (RSVP paints its own theme surface).
### Disabled WPM while synced (decision 6) + the rate escape hatch
Don't just dim it. Replace its content with a voice/lock glyph + "Audio pace"; use
`aria-disabled` (not a dead `disabled`); tooltip "Speed follows audio". Because the
full-screen RSVP overlay hides the TTS rate panel, **tapping it opens a compact TTS rate
picker** (reuse the existing rate options) so rate stays changeable without leaving RSVP —
a one-shot set, which is throttle-safe (unlike a rapid stepper). E-ink: signal disabled with
lock glyph + border, not opacity.
### Decouple UX (decision 4)
First decouple shows a one-time transient toast ("Stopped following audio — tap to resume").
Enumerate the gesture → decouple matrix so it's not invented ad-hoc:
| Gesture | Decouple? |
|---------|-----------|
| Paragraph next/prev/scroll/swipe | Yes |
| Paragraph neutral-zone tap (reveal controls) | No |
| RSVP skip / seek / word-step / progress-drag / chapter-jump / context-word-seek | Yes |
| RSVP center-tap (maps to TTS play/pause when synced) | No |
| RSVP speed-swipe | N/A (speed disabled while synced) |
Re-engage: tap the chip (or the RSVP TTS icon) → re-sync to the current TTS position.
Done-condition addition: toggle Settings → Misc → Eink and verify the indicator, RSVP TTS
icon, and disabled-WPM rendering.
## Data flow (Edge word-level, RSVP open)
```
audio.currentTime ─▶ EdgeTTSClient RAF ─▶ TTSController.dispatchSpeakWord(i)
└▶ 'tts-highlight-word' {cfi} (on controller)
└▶ useTTSControl.handleHighlightWord
└▶ eventDispatcher.dispatch('tts-position', {bookKey, cfi, kind:'word'})
├▶ useParagraphMode → findByRangeAsync → goToParagraph (if changed)
└▶ RSVPControl → controller.syncToCfi(cfi) → seekToIndex
```
Non-Edge swaps the word RAF for sentence marks (`tts-position {kind:'sentence'}`); RSVP
self-paces between marks.
## Eng architecture corrections (from Eng review — auto-adopted)
Both eng voices converged 8/8. Adopted:
1. **Mapping must not be O(N)-from-0 per word.** `RSVPController.findWordIndexByCfi` and
`ParagraphIterator.findByRangeAsync` currently rescan from index 0 each call. At Edge
word rates on a 45k-word chapter this janks (and fixed-layout runs per-word `getCFI`,
catastrophic). Fix: the new public mapper keeps a **monotonic cursor** (start scan at the
last synced index, forward), with a **binary search** over the document-ordered ranges for
seek/decouple/resync. Assert in tests that **no per-word `getCFI` runs**.
2. **Map by containment/overlap, not "first word whose start ≥ target."** The existing
mapper returns the first word starting at-or-after the target, which **skips to the next
word** when TTS lands mid-token. Choose the word whose range *contains/intersects* the
target; only fall back to nearest-following when there's no overlap. (This is what the
"tokenisation mismatch" risk actually needs — the old mapper did not solve it.)
3. **`syncToCfi` no-ops on no-match.** `findWordIndexByCfi` returns `-1` and
`findByRangeAsync` falls back to `first()` — both silently jump to word/paragraph 0. The
sync path must distinguish "no match" (do nothing, stay put) from a real index 0.
4. **Section-generation contract** (the highest-risk bug). When a `tts-position`'s
`sectionIndex` ≠ the mode's current section: (a) do NOT map; (b) enter the `syncing`
indicator state; (c) invalidate the mode's section state and let its own relocate-driven
re-init run (`initIterator` / `loadNextPageContent`); (d) apply only the latest queued CFI
once its section is ready and its `sequence` is still current. Paragraph's TTS-driven focus
must use a **sync-focus path that does NOT arm `isFocusingRef`** — otherwise the 200ms
`isFocusingRef` window eats the TTS relocate, the iterator never re-inits, and it focuses
paragraph 0 of the wrong section.
5. **Gate sync to reflowable for v1.** Fixed-layout (`bookData.isFixedLayout`) → render the
`unsupported` indicator state; don't run the per-word `getCFI` slow path.
6. **Non-Edge estimator guardrails** (D3 kept, so make it degrade well): seed `~190 × ttsRate`
but **hold at the sentence's last word** until the next mark (never advance past the
current sentence's word range), clamp per-word duration to a floor/ceiling, cap snap
distance, and if a correction exceeds a threshold jump once rather than animate a long
catch-up. Compute the sentence's end word index (next mark, or `getSpokenSentence().text`
length) so "hold" has a bound.
7. **`bookKey` scoping:** filter on the full `bookKey` prop (TTS `bookKey` carries a session
suffix that `RSVPController.bookId` strips — comparing `bookId` would misroute in split
view). Validate a start-alignment range's `ownerDocument` matches the live content before
dispatching `tts-speak`.
## Edge cases & risks
- **Tokenisation mismatch.** Edge word boundaries and RSVP's segmenter (Jieba / Intl) split
differently. We map by **CFI/range containment**, not by index equality, so counts need
not match; a TTS word that lands mid-RSVP-word resolves to that RSVP word.
- **Section boundary races.** TTS navigates the view; both modes re-init per section. Order
matters — map only after the iterator/extraction targets the CFI's section.
- **User text selection / page-follow.** Existing TTS page-follow logic must keep working;
the broadcast is additive and must not change current `tts-highlight-*` behavior.
- **Fixed-layout books.** RSVP's CFI path has a fixed-layout branch; verify mapping there or
gate sync to reflowable.
- **Multiple views (split/parallel).** All events are `bookKey`-scoped; every subscriber
filters on it.
- **No regressions when unsynced.** With TTS off, both modes behave exactly as today
(timer-driven RSVP, manual paragraph nav).
## Testing (test-first)
Per `.claude/rules/test-first.md`, write failing tests first. Full matrix in the test-plan
artifact: `~/.gstack/projects/readest/tts-sync-test-plan-20260614.md`. Highlights:
- **Pure mappers (unit):** containment-based CFI→index for paragraph and RSVP (incl. mid-token
/ CJK / hyphen / rewritten-SSML); monotonic-cursor + binary-search correctness; **no-match
returns -1** (not 0). Perf assertion: **no per-word `getCFI`** on Edge word events.
- **Controller emission (unit):** `TTSController` emits `tts-position` with correct
`kind`/`sectionIndex`/monotonic `sequence` from `dispatchSpeakWord`/`dispatchSpeakMark`.
Regression guard: "a handler stopped broadcasting" fails the test.
- **Stale-sequence suppression (unit):** out-of-order events ignored via `lastSequenceSeen`.
- **Non-Edge estimator (unit):** clamp + hold-at-sentence-end + cap-snap; outrun / early-end /
slow+fast voice cases.
- **Decouple matrix (unit/integration):** the gesture→decouple table; re-engage re-syncs.
- **Section transition (browser-e2e, PRIMARY case):** TTS drives the view across a chapter
boundary with paragraph + RSVP both active; assert correct re-init ordering (no wrong-section
paragraph-0), `isFocusingRef` not eaten.
- **Multi-view `bookKey` isolation; fixed-layout `unsupported`** (integration).
**Prerequisite (flagged by Eng review; resolved at gate D6):** the real-`<foliate-view>` TTS
browser-e2e harness the e2e cases need (`src/__tests__/services/tts-auto-advance.browser.test.tsx`
+ memory note `tts-browser-e2e-harness.md`) **is not on `dev`/`main`** — it lives on the
`test/tts-auto-advance-e2e` branch. **Decision: branch #3235 off `test/tts-auto-advance-e2e`**
so the harness is present; rebase if that branch changes before it lands.
Done-conditions: `pnpm test`, `pnpm lint`. No `src-tauri/` or koplugin changes expected.
## Out of scope (v1)
- Word boundaries for Web Speech / Native engines.
- Reverse driving (RSVP WPM or paragraph nav continuously steering TTS) beyond the
user-initiated re-seek.
- Persisting a "synced" preference (coupling is automatic and ephemeral).
- New i18n strings beyond the RSVP audio icon's label/tooltip + the "following audio" indicator.
<!-- AUTONOMOUS DECISION LOG (autoplan) -->
## Decision Audit Trail
CEO dual-voice review (Codex + independent Claude subagent) converged 6/6 on strategic
concerns. Premise gate surfaced four User Challenges; user resolved them as below.
| # | Phase | Decision | Class | Principle | Rationale | Rejected alt |
|---|-------|----------|-------|-----------|-----------|--------------|
| D1 | Intake | Review the #3235 spec doc, base origin/main, skip gstack housekeeping | Mechanical | P3 pragmatic | Only plan that exists; branch in flux; keep focus | branch-setup-first, gstack-upgrade-first |
| D2 | CEO | Keep both modes in **one PR** | User Challenge | user context | Both models recommended split; user kept one PR (shared plumbing, owns roadmap) | split paragraph-first |
| D3 | CEO | Keep the **non-Edge sentence-paced estimator** | User Challenge | user context | Both models called it gold-plating on `190×rate`; user kept it (wants word-motion on all voices) | snap-to-sentence-hold; Edge-only |
| D4 | CEO | Adopt indicator + decouple; **disable** RSVP speed while TTS-driven | User Challenge | P5 explicit | Both models flagged silent coupling surprise + speed→rate 3s-throttle stutter | keep silent coupling + speed→rate |
| A1 | CEO | Run Codex + Claude dual voices | Mechanical | P6 action | Always run both when available | — |
| A2 | CEO | Skip DX phase (Phase 3.5) | Mechanical | — | End-user reading feature, not developer-facing; no DX scope | run DX |
| A3 | Design | Adopt 3-state indicator + placement + voice-glyph + WPM rate-picker + decouple matrix + e-ink rules | Auto (P5/P1) | explicit/complete | Both design voices converged 8/8; additive specs, not reversals; folded into "UI states & placement" | leave UI to implementer |
| A4 | Eng | Controller-owned `tts-position` + sequence + latest-only + `tts-playback-state` | Auto (P5) | explicit | Both eng voices CRIT/HIGH; hook handlers early-return on suppression → silent desync | hook-owned dispatch |
| A5 | Eng | Monotonic-cursor + binary-search mapping; no per-word `getCFI`; containment match; -1 no-op | Auto (P1/P5) | complete | Both flagged O(N)/word jank + mid-token skip | keep first-≥ scan |
| A6 | Eng | Section-generation contract; sync-focus path that doesn't arm `isFocusingRef` | Auto (P5) | explicit | Highest shipped-bug risk (wrong-section paragraph 0) | "re-init on next event" hand-wave |
| A7 | Eng | Gate sync to reflowable v1 (fixed-layout → `unsupported`); estimator clamp/hold/snap-cap | Auto (P3/P1) | pragmatic | Per-word getCFI on fixed-layout catastrophic; estimator drift | full fixed-layout support now |
| A8 | Eng | Write test-plan artifact; flag e2e harness prerequisite (on `test/tts-auto-advance-e2e`, not dev) | Mechanical | complete | Cited harness absent on current checkout | assume harness exists |
| D5 | Gate | **Approve plan as-is** | User | — | All findings addressed or consciously deferred; plan implementation-ready | revise / interrogate |
| D6 | Gate | **Branch #3235 off `test/tts-auto-advance-e2e`** | User | — | e2e harness (section-boundary case) lives there, not on dev/main | land-to-main-first; defer-e2e |
| D7 | Gate | **Gate sync to reflowable for v1** (fixed-layout → unsupported) | User (confirms A7) | P3/P1 | per-word `getCFI` on fixed-layout janks | support fixed-layout now |
## GSTACK REVIEW REPORT
**Status: APPROVED** (gate D5). /autoplan pipeline: CEO → Design → Eng (DX skipped — no
developer-facing scope). Dual voices (Codex `codex-cli 0.134.0` + independent Claude
subagent) each phase. Consensus: CEO 6/6, Design 8/8, Eng 8/8. 3 User Challenges (you kept
one-PR + estimator, adopted coupling refinements); 8 auto-decisions; 3 gate decisions.
Artifacts: this spec (hardened), test plan `~/.gstack/projects/readest/tts-sync-test-plan-20260614.md`,
restore point `~/.gstack/projects/readest/autoplan-restore-tts-sync-20260614-024952.md`.
Next: branch #3235 off `test/tts-auto-advance-e2e`, then writing-plans → implement (test-first).
### CEO findings carried forward (not blocking, for Eng/Design phases)
- **Estimator fidelity (D3 kept):** Eng must pin `ESTIMATED_TTS_WPM` behavior so non-Edge
RSVP degrades gracefully (race-then-snap is the risk). Consider clamping per-word
duration and capping snap distance.
- **Position-broadcast ownership (6-month regret):** prefer the controller emitting the
canonical `tts-position` with the hook forwarding, over hand-mirroring in React handlers,
so a future engine can't silently desync. Add a regression test for "handler stopped
broadcasting."
- **Section-race timing:** new sync path stacks onto existing tuned timers (2000ms TTS
cross-section suppression, 200/100ms paragraph relocate guards, RSVP 150/200ms retries).
Make sync subscribe to the same relocate/highlight signals; write down the ordering
contract; test the section boundary as a primary case.