diff --git a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx index fb50b362..a5199db2 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx @@ -45,6 +45,7 @@ vi.mock('@/store/readerStore', () => { sectionHref: `ch${primaryIndex}.xhtml`, }), getViewSettings: () => null, + getViewState: () => null, }; return { useReaderStore: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), diff --git a/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx index e8808444..95b3c14f 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx @@ -30,6 +30,7 @@ function makeControllerMock() { }), startFromCurrentPosition: vi.fn(), stop: vi.fn(), + pause: vi.fn(), loadNextPageContent: vi.fn(), setExternallyDriven: vi.fn(), stopEstimator: vi.fn(), @@ -73,6 +74,7 @@ vi.mock('@/store/readerStore', () => { monospaceFont: 'Menlo', defaultCJKFont: 'Noto', }), + getViewState: () => null, }; return { useReaderStore: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), @@ -364,6 +366,28 @@ describe('RSVPControl — TTS sync wiring (slice 5, #3235)', () => { expect(handle.current?.ttsSyncStatus).toBe('idle'); }); + test('pauses RSVP (does not keep running) when the TTS session stops', async () => { + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'playing', + }); + }); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'stopped', + }); + }); + // The driving session ended → RSVP must freeze (pause), not resume its own + // auto-advance pacing. + expect(controllerMock.pause).toHaveBeenCalled(); + }); + test('decoupled on a manual nav while playing', async () => { const handle = createRef(); render( diff --git a/apps/readest-app/src/__tests__/document/tts.test.ts b/apps/readest-app/src/__tests__/document/tts.test.ts index 4844d937..7aace9d4 100644 --- a/apps/readest-app/src/__tests__/document/tts.test.ts +++ b/apps/readest-app/src/__tests__/document/tts.test.ts @@ -359,6 +359,59 @@ describe('TTS', () => { }); }); + describe('from() selection start', () => { + // A paragraph whose whole text is one text node (one ), so every + // sentence after the first is a mid-node sub-range — the case where the + // annotation "Speak from selection" misfired. + const SENTENCE_DOC = + '

First sentence here. Those immortals are going crazy. Last one.

'; + + const makeWordRange = (doc: Document, word: string): Range => { + const textNode = doc.querySelector('span')!.firstChild as Text; + const start = textNode.data.indexOf(word); + const range = doc.createRange(); + range.setStart(textNode, start); + range.setEnd(textNode, start + word.length); + return range; + }; + + it('starts at the sentence that contains a mid-sentence selection', () => { + const doc = createHTMLDoc(SENTENCE_DOC, { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence'); + tts.start(); + const ssml = tts.from(makeWordRange(doc, 'immortals')); + + expect(ssml).toBeTruthy(); + const text = stripTags(ssml!); + // starts at the selected word's own sentence (not the next one), and the + // earlier sentence is dropped + expect(text.startsWith('Those immortals')).toBe(true); + expect(text).not.toContain('First sentence here'); + }); + + it('starts at the sentence when the first word of it is selected', () => { + const doc = createHTMLDoc(SENTENCE_DOC, { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence'); + tts.start(); + const ssml = tts.from(makeWordRange(doc, 'Those')); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!).startsWith('Those immortals')).toBe(true); + }); + + it('starts at the last sentence when a word in it is selected', () => { + const doc = createHTMLDoc(SENTENCE_DOC, { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence'); + tts.start(); + const ssml = tts.from(makeWordRange(doc, 'Last')); + + expect(ssml).toBeTruthy(); + const text = stripTags(ssml!); + expect(text.startsWith('Last one')).toBe(true); + expect(text).not.toContain('immortals'); + }); + }); + describe('SSML output correctness', () => { it('should produce valid SSML with speak root element', () => { const doc = createHTMLDoc('

Test content

', { lang: 'en' }); diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index 62b0e86e..26813309 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -146,6 +146,7 @@ vi.mock('@/services/tts', () => ({ backward: vi.fn().mockResolvedValue(undefined), getVoices: vi.fn().mockResolvedValue([]), getVoiceId: vi.fn().mockReturnValue(''), + redispatchPosition: vi.fn(), state: 'idle', addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -253,6 +254,76 @@ describe('useTTSControl concurrent tts-speak events', () => { }); }); +describe('useTTSControl tts-sync-request (mode-entry replay)', () => { + beforeEach(() => { + ttsControllerInstances.length = 0; + pendingInitResolvers.length = 0; + }); + + afterEach(() => { + cleanup(); + }); + + const startSession = async () => { + render(); + await act(async () => { + const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!(); + await p; + }); + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + return ttsControllerInstances[0] as { redispatchPosition: ReturnType }; + }; + + it('replays the current position then the playback state when a session exists', async () => { + const controller = await startSession(); + const order: string[] = []; + controller.redispatchPosition.mockImplementation(() => order.push('position')); + const stateListener = (e: Event) => { + order.push(`state:${(e as CustomEvent).detail.state}`); + }; + eventDispatcher.on('tts-playback-state', stateListener); + + await act(async () => { + await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'book-1' }); + }); + + eventDispatcher.off('tts-playback-state', stateListener); + // Position-before-state is required so RSVP's 'paused' handler (which drops + // following) can't discard the replayed position. + expect(order).toEqual(['position', 'state:playing']); + }); + + it('ignores a sync request for a different book', async () => { + const controller = await startSession(); + controller.redispatchPosition.mockClear(); + + await act(async () => { + await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'other-book' }); + }); + + expect(controller.redispatchPosition).not.toHaveBeenCalled(); + }); + + it('is a no-op once the session has stopped', async () => { + const controller = await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + controller.redispatchPosition.mockClear(); + + await act(async () => { + await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'book-1' }); + }); + + expect(controller.redispatchPosition).not.toHaveBeenCalled(); + }); +}); + describe('useTTSControl handleHighlightMark cross-section navigation', () => { beforeEach(() => { ttsControllerInstances.length = 0; diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index d3d299e3..d8c36eab 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -487,6 +487,29 @@ describe('RSVPController', () => { expect(controller.currentCountdown).toBeNull(); expect(controller.currentState.playing).toBe(true); }); + + test('skips the countdown when externally driven (reusing a TTS session)', () => { + const view = createMockView(0, [makeDoc('one two three')]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.setStartDelay(3); + // TTS owns pacing — no get-ready countdown should show. + controller.setExternallyDriven(true); + controller.start(); + + expect(controller.currentCountdown).toBeNull(); + }); + + test('setExternallyDriven hides a countdown already in progress', () => { + const view = createMockView(0, [makeDoc('one two three')]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.setStartDelay(3); + controller.start(); + expect(controller.currentCountdown).toBe(3); + + // TTS engages mid-countdown (e.g. started from the in-RSVP audio toggle). + controller.setExternallyDriven(true); + expect(controller.currentCountdown).toBeNull(); + }); }); describe('nextWord / prevWord (#4476)', () => { diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index e957e0ca..35bd4664 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -827,6 +827,85 @@ describe('TTSController', () => { }); }); + describe('redispatchPosition', () => { + const makeSentenceRange = () => { + document.body.innerHTML = '

Hello brave world

'; + const textNode = document.body.firstElementChild!.firstChild as Text; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, textNode.length); + return range; + }; + + const armWithSentence = async (range: Range, markName = '0') => { + await controller.initViewTTS(0); + mockView.tts = { + setMark: vi.fn().mockReturnValue(range), + getLastRange: vi.fn().mockImplementation(() => range.cloneRange()), + } as unknown as FoliateView['tts']; + controller.dispatchSpeakMark({ + offset: 0, + name: markName, + text: 'Hello brave world', + language: 'en', + }); + }; + + test('re-emits the current sentence position when not in word mode', async () => { + await armWithSentence(makeSentenceRange()); + vi.mocked(mockView.getCFI).mockReturnValue('cfi-sentence'); + + const listener = vi.fn(); + controller.addEventListener('tts-position', listener); + controller.redispatchPosition(); + + expect(listener).toHaveBeenCalledTimes(1); + const ev = listener.mock.calls[0]![0] as CustomEvent; + expect(ev.detail.kind).toBe('sentence'); + expect(ev.detail.cfi).toBe('cfi-sentence'); + expect(ev.detail.sectionIndex).toBe(0); + expect(typeof ev.detail.sequence).toBe('number'); + }); + + test('re-emits the current word position when in word mode', async () => { + await armWithSentence(makeSentenceRange()); + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + vi.mocked(mockView.getCFI).mockReturnValue('cfi-word'); + controller.dispatchSpeakWord(1); + + const listener = vi.fn(); + controller.addEventListener('tts-position', listener); + controller.redispatchPosition(); + + expect(listener).toHaveBeenCalledTimes(1); + const ev = listener.mock.calls[0]![0] as CustomEvent; + expect(ev.detail.kind).toBe('word'); + expect(ev.detail.cfi).toBe('cfi-word'); + }); + + test('uses a fresh, strictly increasing sequence on each call', async () => { + await armWithSentence(makeSentenceRange()); + vi.mocked(mockView.getCFI).mockReturnValue('cfi-x'); + + const sequences: number[] = []; + controller.addEventListener('tts-position', (e) => { + sequences.push((e as CustomEvent).detail.sequence); + }); + controller.redispatchPosition(); + controller.redispatchPosition(); + + expect(sequences.length).toBe(2); + expect(sequences[1]!).toBeGreaterThan(sequences[0]!); + }); + + test('is a no-op when TTS is inactive (no section)', () => { + const listener = vi.fn(); + controller.addEventListener('tts-position', listener); + controller.redispatchPosition(); + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('getSpokenSentence', () => { test('returns the trimmed text and cfi of the current sentence', async () => { await controller.initViewTTS(0); diff --git a/apps/readest-app/src/__tests__/services/tts-word-highlight.test.ts b/apps/readest-app/src/__tests__/services/tts-word-highlight.test.ts index 61be43cf..5b7d0518 100644 --- a/apps/readest-app/src/__tests__/services/tts-word-highlight.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-word-highlight.test.ts @@ -163,6 +163,42 @@ const makeBaseRangeFrom = (html: string): Range => { return range; }; +describe('rangeTextExcludingInert respects the range offsets', () => { + // A paragraph whose whole text is a single text node (e.g. wrapped in one + // ): a middle sentence is a sub-range of that node with non-zero + // start/end offsets. The text must be the sentence, not the whole node. + it('returns only the sub-range text when the range is inside a single text node', () => { + document.body.innerHTML = + '

First sentence. Second sentence. Third sentence.

'; + const textNode = document.querySelector('span')!.firstChild as Text; + const data = textNode.data; + const base = document.createRange(); + base.setStart(textNode, data.indexOf('Second')); + base.setEnd(textNode, data.indexOf('Third')); + expect(base.toString()).toBe('Second sentence. '); + expect(rangeTextExcludingInert(base)).toBe('Second sentence. '); + }); + + // The actual word-highlight failure: offsets computed from the matching text + // must line up with getTextSubRange (which respects the range offsets), or + // every highlighted word drifts. Reproduces the "second/third sentence" + // skipping seen with single-text-node paragraphs. + it('word offsets from rangeTextExcludingInert align with getTextSubRange mid-node', () => { + document.body.innerHTML = + '

First sentence here. Those immortals are going crazy. Last one.

'; + const textNode = document.querySelector('span')!.firstChild as Text; + const data = textNode.data; + const base = document.createRange(); + base.setStart(textNode, data.indexOf('Those')); + base.setEnd(textNode, data.indexOf('Last')); + const sentenceText = rangeTextExcludingInert(base); + const offsets = computeWordOffsets(sentenceText, ['Those', 'immortals', 'crazy']); + expect(getTextSubRange(base, offsets[0]!.start, offsets[0]!.end)?.toString()).toBe('Those'); + expect(getTextSubRange(base, offsets[1]!.start, offsets[1]!.end)?.toString()).toBe('immortals'); + expect(getTextSubRange(base, offsets[2]!.start, offsets[2]!.end)?.toString()).toBe('crazy'); + }); +}); + describe('gloss-aware word highlighting', () => { it('rangeTextExcludingInert drops cfi-inert gloss text', () => { const base = makeBaseRangeFrom( diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 28824c05..08e775a8 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -1248,9 +1248,14 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ eventDispatcher.dispatch('tts-speak', { bookKey, oneTime, - range: selection.range, + // Clone so clearing the live selection below can't disturb the range + // TTS uses to choose where to start. + range: selection.range.cloneRange(), index: selection.index, }); + // The word was only selected to pick where to start reading; drop the + // selection so its highlight isn't left behind once TTS begins. + view?.deselect(); }; const handleProofread = () => { diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index 3b080f59..ca3b5e24 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -260,6 +260,7 @@ const RSVPControl = forwardRef(function RSV const getView = useReaderStore((s) => s.getView); const getProgress = useReaderStore((s) => s.getProgress); const getViewSettings = useReaderStore((s) => s.getViewSettings); + const getViewState = useReaderStore((s) => s.getViewState); const getBookData = useBookDataStore((s) => s.getBookData); const getConfig = useBookDataStore((s) => s.getConfig); const setConfig = useBookDataStore((s) => s.setConfig); @@ -302,6 +303,24 @@ const RSVPControl = forwardRef(function RSV // sync effect that owns refreshSyncStatus (mirrors the paragraph hook). const refreshSyncStatusRef = useRef<(() => void) | null>(null); + // Whether a TTS session exists (playing or paused), tracked independently of + // RSVP being active so handleStart can decide whether to reuse it (skip the + // start dialog + countdown, start externally driven). Mirrors the exact + // tts-playback-state signal useTTSControl's sync-request replay keys off, so + // the two never disagree — a disagreement would flash a countdown before the + // replay engages. Seeded from the store for sessions already live at mount. + const ttsSessionActiveRef = useRef(false); + useEffect(() => { + ttsSessionActiveRef.current = !!getViewState(bookKey)?.ttsEnabled; + const handlePlaybackState = (event: Event) => { + const detail = (event as CustomEvent).detail as { bookKey?: string; state?: string }; + if (detail?.bookKey !== bookKey) return; + ttsSessionActiveRef.current = detail.state === 'playing' || detail.state === 'paused'; + }; + eventDispatcher.on('tts-playback-state', handlePlaybackState); + return () => eventDispatcher.off('tts-playback-state', handlePlaybackState); + }, [bookKey, getViewState]); + // Re-engage TTS following after a manual nav decoupled it (indicator action). // Sets following back on and re-derives the status; the next tts-position // event re-syncs. No-op when the controller is gone or sync isn't running. @@ -507,6 +526,10 @@ const RSVPControl = forwardRef(function RSV sync.pendingSync = undefined; setTtsEstimated(false); controller.stopEstimator(); + // Freeze RSVP on the current word while audio is paused. In the live + // flow this is already true (set on 'playing'); setting it here also + // covers entering RSVP while TTS is paused, so its own timer never runs. + controller.setExternallyDriven(true); } else if (detail.state === 'stopped') { isPlaying = false; sessionActive = false; @@ -518,6 +541,11 @@ const RSVPControl = forwardRef(function RSV sync.hasWordPositions = false; setTtsEstimated(false); controller.stopEstimator(); + // The driving TTS session ended: freeze RSVP on the current word instead + // of letting its own auto-advance resume. pause() before clearing + // externally-driven so setExternallyDriven(false) (which reschedules the + // next word when playing) leaves it paused. + controller.pause(); controller.setExternallyDriven(false); } refreshSyncStatus(); @@ -591,6 +619,24 @@ const RSVPControl = forwardRef(function RSV reextractForTtsSection, ]); + // Entering RSVP while a TTS session already exists: engage following and ask + // useTTSControl to replay the current playback state + position so RSVP locks + // onto the spoken word immediately, instead of running its own pacing and + // forcing the user to stop and restart TTS inside RSVP. Declared after the + // sync effect so its handlers are registered (in effect-declaration order) + // before the replayed events fire. The request is a no-op when no session + // exists, so plain RSVP (own pacing) is unaffected. Resetting lastSequenceSeen + // lets the replayed position through past any stale sequence from a prior + // session. + useEffect(() => { + if (!isActive) return; + if (!controllerRef.current) return; + if (getBookData(bookKey)?.isFixedLayout) return; + syncStateRef.current.following = true; + syncStateRef.current.lastSequenceSeen = -Infinity; + eventDispatcher.dispatch('tts-sync-request', { bookKey }); + }, [isActive, bookKey, getBookData]); + // One-time-per-session decouple toast: the first time following drops while // TTS still plays, tell the user once. Reset when following re-engages so a // later decouple notifies again. @@ -653,6 +699,14 @@ const RSVPControl = forwardRef(function RSV const controller = controllerRef.current; + // Reuse a live TTS session: start externally-driven so the get-ready + // countdown is skipped and RSVP locks straight onto the spoken word (the + // engage-on-entry effect replays the current position). Set explicitly + // both ways so a session that ended since the last start re-enables the + // countdown. handleStart only runs when RSVP isn't already active. + const ttsSessionActive = ttsSessionActiveRef.current; + controller.setExternallyDriven(ttsSessionActive); + // For Chinese books, preload jieba-wasm so that the synchronous word // extractor can use it. Done before requestStart() so the loader has // the dialog's interaction time to fetch ~3.8MB of WASM. @@ -681,6 +735,16 @@ const RSVPControl = forwardRef(function RSV const choice = (e as CustomEvent).detail; setStartChoice(choice); + // Reusing a live TTS session: don't prompt where to start — the + // engage-on-entry sync overrides the start position anyway, so begin + // immediately (externally driven, no countdown) and let RSVP lock onto + // the spoken word. + if (ttsSessionActive) { + controller.startFromCurrentPosition(); + setIsActive(true); + return; + } + // If there's a saved position or selection, show dialog for user to choose if (choice.hasSavedPosition || choice.hasSelection) { setShowStartDialog(true); diff --git a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts index d2cfea72..40bc1cc5 100644 --- a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts +++ b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts @@ -852,6 +852,21 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => }; }, [paragraphConfig.enabled, isFixedLayout, applySyncCfi, refreshTtsSyncStatus]); + // Entering paragraph mode while a TTS session already exists: engage following + // and ask useTTSControl to replay the current playback state + position so the + // mode syncs to the spoken paragraph immediately, without the user having to + // stop and restart TTS inside the mode. Declared after the follow-listener + // effect so those handlers are registered (in effect-declaration order) before + // the replayed events fire. No-op when no session exists (the request returns + // early) or on fixed-layout (sync unsupported). Resetting lastSequenceSeen lets + // the replayed position through even if a prior session left a higher sequence. + useEffect(() => { + if (!paragraphConfig.enabled || isFixedLayout) return; + followingTtsRef.current = true; + lastSequenceSeenRef.current = -Infinity; + eventDispatcher.dispatch('tts-sync-request', { bookKey: bookKeyRef.current }); + }, [paragraphConfig.enabled, isFixedLayout]); + useEffect(() => { return () => { if (focusResetTimerRef.current) { diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index 971c4c82..d02cc7ef 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -55,6 +55,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp const previousSectionLabelRef = useRef(undefined); const ttsControllerRef = useRef(null); const isStartingTTSRef = useRef(false); + // Last broadcast playback state, so a follower engaging mid-session can be + // replayed the current state on demand (see handleTTSSyncRequest). + const playbackStateRef = useRef<'playing' | 'paused' | 'stopped'>('stopped'); const [ttsController, setTtsController] = useState(null); const [ttsClientsInited, setTtsClientsInitialized] = useState(false); @@ -69,9 +72,30 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp // Broadcast playback transitions on the app-wide bus so consumers that // can't read the hook-local isPlaying flag (RSVP, paragraph mode) can react. const emitPlaybackState = (state: 'playing' | 'paused' | 'stopped') => { + playbackStateRef.current = state; eventDispatcher.dispatch('tts-playback-state', { bookKey, state }); }; + // A follower (paragraph / RSVP mode) that engages mid-session asks the + // controller to re-broadcast its current playback state and position, so it + // can sync immediately instead of waiting for the next word/sentence boundary + // (or forcing the user to stop and restart TTS inside the mode). Replays only + // when a session actually exists (playing or paused). + const handleTTSSyncRequest = (event: CustomEvent) => { + const detail = event.detail as { bookKey?: string } | undefined; + if (detail?.bookKey !== bookKey) return; + const state = playbackStateRef.current; + if (state !== 'playing' && state !== 'paused') return; + if (!ttsControllerRef.current) return; + // Position first, then state: RSVP's 'paused' handler drops following, which + // would discard a position arriving after it. Position-first lets the + // follower sync the current word/paragraph before a (possibly paused) state + // lands. Only the entering mode listens to these events, so the order is + // deterministic. The live flow (separate emits) is unaffected. + ttsControllerRef.current.redispatchPosition(); + emitPlaybackState(state); + }; + const handleTTSForward = async (event: CustomEvent) => { const detail = event.detail as { bookKey: string; byMark?: boolean } | undefined; if (detail?.bookKey !== bookKey) return; @@ -144,6 +168,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay); eventDispatcher.on('tts-set-rate', handleTTSSetRate); eventDispatcher.on('tts-highlight-sentence', handleTTSHighlightSentence); + eventDispatcher.on('tts-sync-request', handleTTSSyncRequest); return () => { eventDispatcher.off('tts-speak', handleTTSSpeak); eventDispatcher.off('tts-stop', handleTTSStop); @@ -152,6 +177,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay); eventDispatcher.off('tts-set-rate', handleTTSSetRate); eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence); + eventDispatcher.off('tts-sync-request', handleTTSSyncRequest); if (ttsControllerRef.current) { ttsControllerRef.current.shutdown(); ttsControllerRef.current = null; diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index c0ef8756..62b3be49 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -518,9 +518,12 @@ export class RSVPController extends EventTarget { private startCountdown(onComplete: () => void): void { this.clearCountdown(); - // A delay of 0 means instant start — skip the countdown entirely. + // A delay of 0 means instant start — skip the countdown entirely. When TTS + // owns pacing (externally driven, e.g. RSVP entered while TTS is playing), + // there is nothing to "get ready" for, so skip it too — the spoken word + // shows immediately and advancement is driven by syncToCfi. let count = this.state.startDelaySeconds; - if (count <= 0) { + if (count <= 0 || this.#externallyDriven) { onComplete(); return; } @@ -817,6 +820,9 @@ export class RSVPController extends EventTarget { if (on) { // Stop any pending auto-advance immediately. this.clearTimer(); + // TTS owns pacing now; a get-ready countdown is meaningless, so hide one + // already in progress (e.g. TTS started from the in-RSVP audio toggle). + this.clearCountdown(); } else { // Leaving external-drive mode: kill any estimator pacing first so it // can't keep advancing, then resume normal auto-advance if playing. diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 2f8d11b0..17511c4f 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -679,6 +679,31 @@ export class TTSController extends EventTarget { } } + // Re-emit the controller's current position on the canonical 'tts-position' + // signal with a fresh (monotonic) sequence. Lets a follower that engages + // mid-session (paragraph / RSVP mode entered while TTS is already playing or + // paused) sync to the current position without waiting for the next word or + // sentence boundary. Mirrors reapplyCurrentHighlight's word-vs-sentence + // choice, but dispatches a position instead of drawing a highlight. + redispatchPosition() { + if (this.#ttsSectionIndex < 0) return; + if (this.#wordHighlightActive && this.#lastSpeakWordRange) { + try { + const cfi = this.view.getCFI(this.#ttsSectionIndex, this.#lastSpeakWordRange); + if (cfi) { + this.#dispatchPosition(cfi, 'word'); + return; + } + } catch {} + } + const range = this.view.tts?.getLastRange(); + if (!range) return; + try { + const cfi = this.view.getCFI(this.#ttsSectionIndex, range); + if (cfi) this.#dispatchPosition(cfi, 'sentence'); + } catch {} + } + // Word-level highlighting within the chunk of the last dispatched mark, // driven by TTS clients that report word boundaries (Edge TTS). It only // swaps the visual highlight from the sentence to the spoken word — @@ -689,8 +714,24 @@ export class TTSController extends EventTarget { const range = this.view.tts?.getLastRange(); if (!range) return; this.#speakWordBaseRange = range; - this.#speakWordOffsets = computeWordOffsets(rangeTextExcludingInert(range), words); + const matchText = rangeTextExcludingInert(range); + this.#speakWordOffsets = computeWordOffsets(matchText, words); this.#speakWordRanges = []; + if (process.env.NODE_ENV !== 'production') { + // Dev-only trace of the Edge word-sync: each spoken (boundary) word vs the + // text it actually highlights. A drifted or "(unmatched)" mapping — or an + // empty word list — pinpoints word-highlight bugs without instrumenting + // the overlayer by hand. `process.env.NODE_ENV` is statically inlined, so + // this whole block is dropped from production builds. + const mapping = words.map((word, i) => { + const offset = this.#speakWordOffsets[i]; + const highlighted = offset + ? getTextSubRange(range, offset.start, offset.end)?.toString() + : ''; + return { spoken: word, highlighted: highlighted || '(unmatched)' }; + }); + console.log('[TTS] word-sync', { sentence: matchText, words: mapping }); + } if (words.length === 0) { // No word boundaries for this chunk: the sentence highlight was // suppressed at mark dispatch, so draw it now as the fallback. diff --git a/apps/readest-app/src/services/tts/wordHighlight.ts b/apps/readest-app/src/services/tts/wordHighlight.ts index c6e7b05c..d27e6681 100644 --- a/apps/readest-app/src/services/tts/wordHighlight.ts +++ b/apps/readest-app/src/services/tts/wordHighlight.ts @@ -34,7 +34,12 @@ export const rangeTextExcludingInert = (base: Range): string => { const root = base.commonAncestorContainer; const doc = root.ownerDocument ?? (root as Document); if (root.nodeType === Node.TEXT_NODE) { - return isInertText(root) ? '' : (root as Text).data; + // A range contained in one text node (e.g. a middle sentence of a paragraph + // wrapped in a single ) must honour the range offsets, exactly as + // getTextSubRange does. Returning the whole node would make word offsets + // drift relative to the highlighted sub-range. + if (isInertText(root)) return ''; + return (root as Text).data.slice(base.startOffset, base.endOffset); } const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); let out = ''; diff --git a/apps/readest-app/vitest.setup.ts b/apps/readest-app/vitest.setup.ts index 092ee087..1df5d236 100644 --- a/apps/readest-app/vitest.setup.ts +++ b/apps/readest-app/vitest.setup.ts @@ -1,3 +1,44 @@ +// jsdom does not implement the CSS namespace; foliate-js TTS uses CSS.escape +// (mark[name="…"] lookups). Provide the standard polyfill so those paths work. +const globalWithCSS = globalThis as { CSS?: { escape?: (value: string) => string } }; +if (!globalWithCSS.CSS) globalWithCSS.CSS = {}; +if (typeof globalWithCSS.CSS.escape !== 'function') { + globalWithCSS.CSS.escape = (value: string): string => { + const string = String(value); + const length = string.length; + const firstCodeUnit = string.charCodeAt(0); + let result = ''; + let index = -1; + while (++index < length) { + const codeUnit = string.charCodeAt(index); + if (codeUnit === 0x0000) { + result += '�'; + } else if ( + (codeUnit >= 0x0001 && codeUnit <= 0x001f) || + codeUnit === 0x007f || + (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d) + ) { + result += '\\' + codeUnit.toString(16) + ' '; + } else if (index === 0 && length === 1 && codeUnit === 0x002d) { + result += '\\' + string.charAt(index); + } else if ( + codeUnit >= 0x0080 || + codeUnit === 0x002d || + codeUnit === 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) + ) { + result += string.charAt(index); + } else { + result += '\\' + string.charAt(index); + } + } + return result; + }; +} + // matchMedia mock if (typeof window !== 'undefined' && !window.matchMedia) { window.matchMedia = (query: string) => diff --git a/packages/foliate-js b/packages/foliate-js index d5e197c3..a7b5118c 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit d5e197c3de5f457ddc0e6b93b7932ebabdcb825b +Subproject commit a7b5118c7832e65bba7a9f3e25a312559fad821d