---
name: tts-reuse-session-mode-entry
description: Paragraph/RSVP modes auto-sync to a live TTS session on entry (no stop/restart)
metadata:
  node_type: memory
  type: project
  originSessionId: tts-wordsync-drift
---

# Reuse the live TTS session when entering Paragraph / RSVP mode

Goal: TTS started in normal mode keeps playing and the visual mode syncs to it the moment you enter Paragraph or RSVP — previously you had to stop TTS and restart it inside the mode. Scope chosen: enter-from-normal only (exit-to-normal already kept playing since normal mode highlights natively from the same controller). Paused sessions engage too (synced-but-paused).

**Root cause of the old behavior.** Both modes engaged following only inside their `tts-playback-state: 'playing'` handler branch (`followingTtsRef`/`syncState.following = true`). Entering a mode while TTS was ALREADY playing registered the listeners but no fresh `'playing'` event fired, so following never engaged and incoming `tts-position` events were dropped (`if (!following) return`). The modes reacted to future state changes, never the current one.

**Fix = replay current state on mode entry (Approach A, event-bus request/response).**
1. `TTSController.redispatchPosition()` — re-emits the current position via the existing private `#dispatchPosition` (fresh monotonic sequence so it isn't dropped as stale). Word cfi if `#wordHighlightActive`+`#lastSpeakWordRange`, else `getLastRange()` sentence; no-op if `#ttsSectionIndex < 0`.
2. `useTTSControl`: cache last emitted state in `playbackStateRef` (set in `emitPlaybackState`); new `tts-sync-request {bookKey}` listener (registered with tts-speak/tts-stop). On a matching request with a live session (state playing|paused), it **redispatches position FIRST, then re-emits the playback state**. Order matters: RSVP's `'paused'` handler sets `following=false`, which would discard a position arriving after it; position-first lets the follower sync the word before a paused state lands. Only the entering mode listens, so order is deterministic.
3. Paragraph (`useParagraphMode`) & RSVP (`RSVPControl`): a new effect, declared AFTER the follow-listener effect (so handlers register first in effect-declaration order), runs on entry — sets following=true, resets lastSequenceSeen to -Infinity (lets the replay through past a prior session's higher sequence), and dispatches `tts-sync-request`. Gated off on fixed-layout. No-op when no session (request returns early), so plain mode usage is unaffected.
4. RSVP only: its `'paused'` branch now also calls `controller.setExternallyDriven(true)` so entering RSVP while TTS is paused freezes RSVP's own timer (live flow already had it true from 'playing'; matches the branch's stated intent).

**Tests.** `redispatchPosition` unit tests (tts-controller.test.ts: word/sentence kind, fresh increasing sequence, no-op when inactive). `useTTSControl` integration tests (useTTSControl.test.tsx: replays position-before-state, ignores other bookKey, no-op once stopped) — mock controller needs a `redispatchPosition: vi.fn()`.

**RSVP: don't show the get-ready countdown when reusing a TTS session** (follow-up). RSVP normally counts down `startDelaySeconds` (default 3) before its own pacing; meaningless when TTS drives. Fix: (a) `RSVPController.startCountdown` skips (instant `onComplete`) when `#externallyDriven`; (b) `setExternallyDriven(true)` also `clearCountdown()` (covers TTS engaging mid-countdown, e.g. in-RSVP audio toggle); (c) `RSVPControl.handleStart` calls `controller.setExternallyDriven(!!getViewState(bookKey)?.ttsEnabled)` right after creating the controller — BEFORE start/dialog — so the countdown never starts (flash-free) and re-enables for plain RSVP. `ttsEnabled` (readerStore viewState, per-book) is the live-session signal (true from speak success → false on stop). Set explicitly both ways since handleStart only runs when RSVP isn't already active. Two RSVP component test mocks (rsvp-tts-sync, rsvp-section-advance) needed `getViewState` added or `getViewState is not a function` cascades 9 failures. Tests: rsvp-controller.test.ts (skip when externally driven; setExternallyDriven hides in-progress countdown).

**RSVP entry-flow refinements (follow-ups).** (1) Don't show the "Start RSVP Reading" dialog when reusing a TTS session — in `handleStart`'s `handleStartChoice`, if a session is active, `startFromCurrentPosition()` + setIsActive immediately (the engage-on-entry sync overrides the start position anyway, so prompting is pointless). (2) When the TTS session **stops**, RSVP must freeze, not resume its own pacing — the `'stopped'` branch of `handlePlaybackState` now calls `controller.pause()` BEFORE `setExternallyDriven(false)` (the latter calls `scheduleNextWord()` when `playing`, which was restarting RSVP's timer — the "it still runs" bug). (3) Robust session detection: a `ttsEnabled` skew (store flag false while the replay's `playbackStateRef` was playing/paused) caused a countdown to flash then clear (startCountdown ran, then the replay's `setExternallyDriven(true)` cleared it). Fixed with an always-on `tts-playback-state` listener in RSVPControl maintaining `ttsSessionActiveRef` (seeded from `getViewState().ttsEnabled` at mount) — it mirrors the EXACT signal the replay keys off, so the countdown/dialog gate can never disagree with the replay. `handleStart` reads that ref, not the store. Tests: rsvp-tts-sync.test.tsx "pauses RSVP … when the TTS session stops" (mock controller needs `pause`).

**Env gotcha that blocks live re-verification:** the book open in TWO tabs (user session + automation tab) floods `NoModificationAllowedError: createSyncAccessHandle … another open Access Handle` (OPFS), which intermittently fails TTS section-doc init → `view.tts` null, `ttsEnabled` false, TTS won't start. Close the duplicate tab for a clean single-tab session before live TTS testing.

**Live-verified (dev-web, Chrome): BOTH modes.** Paragraph: start TTS in normal mode (select word → popup headphone) → View menu → Paragraph Mode (or Shift+P): focus overlay opens already "🔊 Following audio" on the spoken paragraph, follows word-by-word; bar shows "Pause audio" (engaged). RSVP: with TTS playing, Shift+V → start dialog → From Current Page → RSVP overlay opens showing the spoken word ("As") with **no countdown** and "🔊 Following audio". Gotchas: claude-in-chrome viewport auto-resizes (use element refs from read_page, not coordinates); focus-mode overlay swallows Shift+P/Escape (exit via the bar's "Exit Paragraph Mode" ref); book open in two tabs → OPFS `NoModificationAllowedError` + flaky `view.tts`. Related: [[tts-sync-paragraph-rsvp-3235]] [[edge-tts-word-highlighting-4017]] [[tts-word-highlight-singletextnode-drift]].
