* 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:
@@ -3,6 +3,7 @@ import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice }
|
||||
import { containsCJK, isCJKPunctuation, splitTextIntoWords, getHyphenParts } from './utils';
|
||||
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
|
||||
import { XCFI } from '@/utils/xcfi';
|
||||
import { isRangeLike } from '@/utils/range';
|
||||
|
||||
const DEFAULT_WPM = 300;
|
||||
const MIN_WPM = 100;
|
||||
@@ -14,6 +15,20 @@ const DEFAULT_SPLIT_HYPHENS = false;
|
||||
const DEFAULT_CJK_CHAR_MODE = false;
|
||||
const DEFAULT_START_DELAY_SECONDS = 3;
|
||||
const START_DELAY_OPTIONS = [0, 1, 2, 3];
|
||||
|
||||
// Slice 5 (#3235): non-Edge TTS estimator. Sentence-only voices give us one
|
||||
// mark per sentence; RSVP jumps to the sentence's first word then SELF-PACES
|
||||
// forward through the following words at a rate estimated from the TTS voice
|
||||
// rate, until the next sentence mark snaps it back into alignment.
|
||||
// wpm = clamp(BASE * ttsRate, FLOOR, CEIL)
|
||||
const ESTIMATED_TTS_WPM_BASE = 190;
|
||||
const ESTIMATED_TTS_WPM_FLOOR = 60;
|
||||
const ESTIMATED_TTS_WPM_CEIL = 600;
|
||||
// Cap self-advance to this many words past the sentence's first word. The
|
||||
// sentence end isn't known until the next mark arrives, so without a cap a fast
|
||||
// estimate could outrun the audio across the rest of the section. At the cap the
|
||||
// estimator HOLDS (stops its timer) and waits for the next snap.
|
||||
const ESTIMATED_MAX_WORDS_AHEAD = 60;
|
||||
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
|
||||
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
|
||||
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
|
||||
@@ -51,6 +66,22 @@ export class RSVPController extends EventTarget {
|
||||
private countdown: number | null = null;
|
||||
private cachedWords: { docIndex: number; doc: Document; words: RsvpWord[] } | null = null;
|
||||
|
||||
// Slice 3a (#3235): externally-driven sync (e.g. TTS drives RSVP word display).
|
||||
// #lastSyncIndex is a monotonic cursor so forward word-by-word sync scans from
|
||||
// the previous match (~O(1)) instead of from 0; backward seeks binary-search.
|
||||
#lastSyncIndex = -1;
|
||||
// While true, scheduleNextWord becomes a no-op so the auto-advance timer never
|
||||
// fires — the external driver owns advancement via syncToCfi.
|
||||
#externallyDriven = false;
|
||||
|
||||
// Slice 5 (#3235): non-Edge estimator pacing. Its own timer + the cap anchor
|
||||
// it self-advances from. Kept separate from playbackTimer so the estimator and
|
||||
// the normal WPM auto-advance can never co-run; both are gated by
|
||||
// #externallyDriven anyway, but separate state keeps the cap bookkeeping clean.
|
||||
#estimatorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#estimatorAnchorIndex = -1;
|
||||
#estimatorWpm = ESTIMATED_TTS_WPM_BASE;
|
||||
|
||||
constructor(view: FoliateView, bookKey: string, primaryLanguage?: string) {
|
||||
super();
|
||||
this.view = view;
|
||||
@@ -396,7 +427,9 @@ export class RSVPController extends EventTarget {
|
||||
return null;
|
||||
}
|
||||
const anchor = resolved.anchor(target.doc);
|
||||
if (anchor instanceof Range) return anchor;
|
||||
// Realm-safe: anchor is an iframe-realm Range, so `instanceof Range` (top
|
||||
// realm) is always false. Duck-type instead (cross-realm instanceof).
|
||||
if (isRangeLike(anchor)) return anchor;
|
||||
if (anchor && anchor instanceof target.doc.defaultView!.Node) {
|
||||
const range = target.doc.createRange();
|
||||
range.selectNode(anchor as Node);
|
||||
@@ -449,6 +482,8 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
const clampedStart = words.length > 0 ? Math.min(words.length - 1, Math.max(0, startIndex)) : 0;
|
||||
// New word list => the sync cursor from any previous section is stale.
|
||||
this.#lastSyncIndex = -1;
|
||||
this.state = {
|
||||
...this.state,
|
||||
active: true,
|
||||
@@ -544,6 +579,8 @@ export class RSVPController extends EventTarget {
|
||||
|
||||
this.clearTimer();
|
||||
this.clearCountdown();
|
||||
this.stopEstimator();
|
||||
this.#lastSyncIndex = -1;
|
||||
this.state = {
|
||||
...this.state,
|
||||
active: false,
|
||||
@@ -698,18 +735,27 @@ export class RSVPController extends EventTarget {
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
// Slice 5 (#3235): a user-initiated jump (skip/seek/word-step) decouples RSVP
|
||||
// from TTS following. Emitted so the TTS-sync wiring can set following=false;
|
||||
// sync/estimator paths set currentIndex directly and never fire this.
|
||||
private emitManualNav(): void {
|
||||
this.dispatchEvent(new CustomEvent('rsvp-manual-nav'));
|
||||
}
|
||||
|
||||
skipForward(count: number = 10): void {
|
||||
this.state.currentIndex = Math.min(
|
||||
this.state.words.length - 1,
|
||||
this.state.currentIndex + count,
|
||||
);
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
skipBackward(count: number = 10): void {
|
||||
this.state.currentIndex = Math.max(0, this.state.currentIndex - count);
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
@@ -723,6 +769,7 @@ export class RSVPController extends EventTarget {
|
||||
this.state.currentIndex + 1,
|
||||
);
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
@@ -730,6 +777,7 @@ export class RSVPController extends EventTarget {
|
||||
if (this.state.playing) this.pause();
|
||||
this.state.currentIndex = Math.max(0, this.state.currentIndex - 1);
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
@@ -738,6 +786,7 @@ export class RSVPController extends EventTarget {
|
||||
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
|
||||
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
@@ -745,9 +794,237 @@ export class RSVPController extends EventTarget {
|
||||
if (this.state.words.length === 0) return;
|
||||
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index));
|
||||
this.state.currentPartIndex = 0;
|
||||
this.emitManualNav();
|
||||
this.emitStateChange();
|
||||
}
|
||||
|
||||
// Slice 5 (#3235): cap exposed for the non-Edge estimator's tests + callers.
|
||||
static readonly ESTIMATED_MAX_WORDS_AHEAD = ESTIMATED_MAX_WORDS_AHEAD;
|
||||
|
||||
// Slice 5 (#3235): estimated reading rate for a non-Edge TTS voice rate.
|
||||
// clamp(BASE * ttsRate, FLOOR, CEIL)
|
||||
static estimatedWpmFromRate(ttsRate: number): number {
|
||||
const wpm = ESTIMATED_TTS_WPM_BASE * (ttsRate || 1);
|
||||
return Math.max(ESTIMATED_TTS_WPM_FLOOR, Math.min(ESTIMATED_TTS_WPM_CEIL, wpm));
|
||||
}
|
||||
|
||||
// Slice 3a (#3235): suspend/restore the auto-advance timer so an external
|
||||
// driver (e.g. TTS) can own word advancement via syncToCfi without the
|
||||
// controller's own setTimeout racing it.
|
||||
setExternallyDriven(on: boolean): void {
|
||||
if (this.#externallyDriven === on) return;
|
||||
this.#externallyDriven = on;
|
||||
if (on) {
|
||||
// Stop any pending auto-advance immediately.
|
||||
this.clearTimer();
|
||||
} else {
|
||||
// Leaving external-drive mode: kill any estimator pacing first so it
|
||||
// can't keep advancing, then resume normal auto-advance if playing.
|
||||
this.stopEstimator();
|
||||
if (this.state.playing && this.state.active) {
|
||||
this.scheduleNextWord();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 5 (#3235): drive RSVP from a non-Edge sentence mark. Jumps to the
|
||||
// sentence's first word (syncToCfi — corrects any drift; that's the "snap"),
|
||||
// then SELF-PACES forward through the following words on a timer at the given
|
||||
// estimated wpm. Capped at ESTIMATED_MAX_WORDS_AHEAD past the anchor so a fast
|
||||
// estimate can't outrun the audio past the (still-unknown) sentence end; at
|
||||
// the cap it HOLDS until the next drive. No-op (and leaves the cursor put) if
|
||||
// the cfi can't be resolved in this section.
|
||||
driveEstimatedFromCfi(cfi: string, wpm: number): boolean {
|
||||
// Stop any in-flight estimator pacing before re-anchoring (snap).
|
||||
this.stopEstimator();
|
||||
if (!this.syncToCfi(cfi)) return false;
|
||||
this.#estimatorAnchorIndex = this.state.currentIndex;
|
||||
this.#estimatorWpm = Math.max(ESTIMATED_TTS_WPM_FLOOR, Math.min(ESTIMATED_TTS_WPM_CEIL, wpm));
|
||||
this.scheduleEstimatorAdvance();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slice 5 (#3235): cancel estimator pacing (e.g. disengage / stop / snap).
|
||||
stopEstimator(): void {
|
||||
if (this.#estimatorTimer) {
|
||||
clearTimeout(this.#estimatorTimer);
|
||||
this.#estimatorTimer = null;
|
||||
}
|
||||
this.#estimatorAnchorIndex = -1;
|
||||
}
|
||||
|
||||
private scheduleEstimatorAdvance(): void {
|
||||
if (this.#estimatorTimer) {
|
||||
clearTimeout(this.#estimatorTimer);
|
||||
this.#estimatorTimer = null;
|
||||
}
|
||||
if (!this.#externallyDriven || this.#estimatorAnchorIndex < 0) return;
|
||||
|
||||
// HOLD at the cap: never advance more than MAX_WORDS_AHEAD past the anchor.
|
||||
const capIndex = Math.min(
|
||||
this.state.words.length - 1,
|
||||
this.#estimatorAnchorIndex + ESTIMATED_MAX_WORDS_AHEAD,
|
||||
);
|
||||
if (this.state.currentIndex >= capIndex) return;
|
||||
|
||||
const perWordMs = 60000 / this.#estimatorWpm;
|
||||
this.#estimatorTimer = setTimeout(() => {
|
||||
this.#estimatorTimer = null;
|
||||
if (!this.#externallyDriven || this.#estimatorAnchorIndex < 0) return;
|
||||
const next = Math.min(this.state.words.length - 1, this.state.currentIndex + 1);
|
||||
if (next === this.state.currentIndex) return;
|
||||
this.state.currentIndex = next;
|
||||
this.state.currentPartIndex = 0;
|
||||
this.#lastSyncIndex = next;
|
||||
this.emitStateChange();
|
||||
this.scheduleEstimatorAdvance();
|
||||
}, perWordMs);
|
||||
}
|
||||
|
||||
// Slice 3a (#3235): drive the displayed word from an external CFI (e.g. the
|
||||
// word/sentence TTS is currently speaking). Resolves the CFI to a DOM range
|
||||
// via the same fast path findWordIndexByCfi uses (no per-word view.getCFI),
|
||||
// maps it to a word by CONTAINMENT (fixing the mid-token skip), and displays
|
||||
// that word WITHOUT arming the auto-advance timer. Returns false (leaving
|
||||
// currentIndex untouched) when the CFI can't be resolved in this section.
|
||||
syncToCfi(cfi: string): boolean {
|
||||
const words = this.state.words;
|
||||
if (words.length === 0) return false;
|
||||
|
||||
const targetSpineIndex = this.getSpineIndex(cfi);
|
||||
if (targetSpineIndex < 0) return false;
|
||||
|
||||
const targetRange = this.resolveCfiToRange(cfi, targetSpineIndex);
|
||||
if (!targetRange) return false;
|
||||
|
||||
const index = this.findWordIndexContaining(words, targetRange, targetSpineIndex);
|
||||
if (index < 0) return false;
|
||||
|
||||
this.#lastSyncIndex = index;
|
||||
this.state.currentIndex = index;
|
||||
this.state.currentPartIndex = 0;
|
||||
// Display the word but do NOT call scheduleNextWord(): the external driver
|
||||
// controls advancement. This mirrors the seek display path (set index +
|
||||
// emit) minus the timer.
|
||||
this.emitStateChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Map a resolved target range to a word by CONTAINMENT: the word whose range
|
||||
// contains the target's start position. If the target falls in a gap
|
||||
// (whitespace between words), fall back to the nearest FOLLOWING word.
|
||||
//
|
||||
// Uses a monotonic cursor (#lastSyncIndex): forward syncs scan from the last
|
||||
// match (~O(1)); a target before the cursor binary-searches the
|
||||
// document-ordered word ranges.
|
||||
private findWordIndexContaining(
|
||||
words: RsvpWord[],
|
||||
targetRange: Range,
|
||||
targetSpineIndex: number,
|
||||
): number {
|
||||
const cursor = this.#lastSyncIndex;
|
||||
|
||||
// Decide direction: if the target starts before the cursor word, binary
|
||||
// search backward; otherwise linear-scan forward from the cursor.
|
||||
let backward = false;
|
||||
if (cursor >= 0 && cursor < words.length) {
|
||||
const cursorRange = words[cursor]?.range;
|
||||
if (cursorRange && words[cursor]?.docIndex === targetSpineIndex) {
|
||||
try {
|
||||
// target.start < cursor.start => cursor.start > target.start
|
||||
backward = cursorRange.compareBoundaryPoints(Range.START_TO_START, targetRange) > 0;
|
||||
} catch {
|
||||
backward = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (backward) {
|
||||
const found = this.binarySearchWord(words, targetRange, targetSpineIndex);
|
||||
if (found >= 0) return found;
|
||||
// Fall through to a forward scan from 0 if binary search couldn't decide.
|
||||
}
|
||||
|
||||
return this.linearScanWord(
|
||||
words,
|
||||
targetRange,
|
||||
targetSpineIndex,
|
||||
backward ? 0 : Math.max(0, cursor),
|
||||
);
|
||||
}
|
||||
|
||||
// Forward linear scan from `from`. Returns the containing word, else the
|
||||
// nearest following word (gap fallback).
|
||||
private linearScanWord(
|
||||
words: RsvpWord[],
|
||||
targetRange: Range,
|
||||
targetSpineIndex: number,
|
||||
from: number,
|
||||
): number {
|
||||
let firstFollowing = -1;
|
||||
for (let i = from; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (!word?.range || word.docIndex !== targetSpineIndex) continue;
|
||||
const rel = this.compareWordToTarget(word.range, targetRange);
|
||||
if (rel === 0) return i; // contains the target start
|
||||
if (rel > 0 && firstFollowing < 0) firstFollowing = i; // first word after target
|
||||
}
|
||||
return firstFollowing;
|
||||
}
|
||||
|
||||
// Binary search over the document-ordered word ranges for the word containing
|
||||
// the target start; if none contains it, return the nearest following word.
|
||||
private binarySearchWord(
|
||||
words: RsvpWord[],
|
||||
targetRange: Range,
|
||||
targetSpineIndex: number,
|
||||
): number {
|
||||
let lo = 0;
|
||||
let hi = words.length - 1;
|
||||
let firstFollowing = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const word = words[mid];
|
||||
if (!word?.range || word.docIndex !== targetSpineIndex) {
|
||||
// Ranges without a comparable range break ordering; fall back to a
|
||||
// linear scan from the low bound.
|
||||
return this.linearScanWord(words, targetRange, targetSpineIndex, lo);
|
||||
}
|
||||
const rel = this.compareWordToTarget(word.range, targetRange);
|
||||
if (rel === 0) return mid;
|
||||
if (rel < 0) {
|
||||
// word is entirely before the target — search right.
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
// word starts after the target — candidate following word.
|
||||
firstFollowing = mid;
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return firstFollowing;
|
||||
}
|
||||
|
||||
// Classify a word range relative to a collapsed target position:
|
||||
// -1 : word ends at-or-before the target start (word is before the target)
|
||||
// 0 : word contains the target start (word.start <= target < word.end)
|
||||
// +1 : word starts after the target start (word is after the target)
|
||||
private compareWordToTarget(wordRange: Range, targetRange: Range): number {
|
||||
try {
|
||||
// DOM compareBoundaryPoints(how, source) semantics:
|
||||
// START_TO_START -> this.start vs source.start
|
||||
// START_TO_END -> this.end vs source.start
|
||||
// (END_TO_START is this.start vs source.end — NOT what we want here.)
|
||||
const startCmp = wordRange.compareBoundaryPoints(Range.START_TO_START, targetRange);
|
||||
if (startCmp > 0) return 1; // word starts after the target start
|
||||
// word.start <= target.start. Check word.end vs target.start.
|
||||
const endCmp = wordRange.compareBoundaryPoints(Range.START_TO_END, targetRange);
|
||||
if (endCmp > 0) return 0; // word.end > target.start => contains the target start
|
||||
return -1; // word.end <= target.start => word entirely before the target
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
loadNextPageContent(retryCount = 0): void {
|
||||
this.clearTimer();
|
||||
const words = this.extractWordsWithRanges();
|
||||
@@ -761,6 +1038,8 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
const wasPlaying = this.state.playing;
|
||||
// New section => the sync cursor from the previous section is stale.
|
||||
this.#lastSyncIndex = -1;
|
||||
this.state = {
|
||||
...this.state,
|
||||
playing: false,
|
||||
@@ -804,6 +1083,8 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-segmentation invalidates the prior word indices the cursor referenced.
|
||||
this.#lastSyncIndex = -1;
|
||||
this.state = {
|
||||
...this.state,
|
||||
words,
|
||||
@@ -825,6 +1106,10 @@ export class RSVPController extends EventTarget {
|
||||
private scheduleNextWord(): void {
|
||||
this.clearTimer();
|
||||
|
||||
// When externally driven (e.g. by TTS), the driver advances words via
|
||||
// syncToCfi; the auto-advance timer must never arm.
|
||||
if (this.#externallyDriven) return;
|
||||
|
||||
if (!this.state.playing || !this.state.active) return;
|
||||
|
||||
if (this.state.currentIndex >= this.state.words.length) {
|
||||
|
||||
@@ -36,6 +36,10 @@ export class TTSController extends EventTarget {
|
||||
|
||||
#ttsSectionIndex: number = -1;
|
||||
|
||||
// Monotonic counter for the canonical 'tts-position' event so downstream
|
||||
// consumers (paragraph mode, RSVP) can drop out-of-order positions.
|
||||
#positionSequence: number = 0;
|
||||
|
||||
// Word-level highlight state for the currently spoken chunk. Armed by a
|
||||
// successful dispatchSpeakMark, populated by prepareSpeakWords when a TTS
|
||||
// client has word-boundary metadata for the chunk.
|
||||
@@ -595,6 +599,22 @@ export class TTSController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical position signal emitted from the same paths as
|
||||
// tts-highlight-mark / tts-highlight-word. The controller is the source of
|
||||
// truth (it owns the section index and current word/sentence CFI).
|
||||
#dispatchPosition(cfi: string, kind: 'word' | 'sentence') {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('tts-position', {
|
||||
detail: {
|
||||
cfi,
|
||||
kind,
|
||||
sectionIndex: this.#ttsSectionIndex,
|
||||
sequence: ++this.#positionSequence,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
dispatchSpeakMark(mark?: TTSMark) {
|
||||
this.#resetSpeakWords();
|
||||
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } }));
|
||||
@@ -610,6 +630,7 @@ export class TTSController extends EventTarget {
|
||||
this.#speakWordsArmed = !!range;
|
||||
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
|
||||
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } }));
|
||||
this.#dispatchPosition(cfi, 'sentence');
|
||||
} catch {
|
||||
this.#suppressMarkHighlight = false;
|
||||
}
|
||||
@@ -697,6 +718,7 @@ export class TTSController extends EventTarget {
|
||||
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
|
||||
if (cfi) {
|
||||
this.dispatchEvent(new CustomEvent('tts-highlight-word', { detail: { cfi } }));
|
||||
this.#dispatchPosition(cfi, 'word');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user