feat(tts): reuse the speaking session across paragraph & RSVP modes (#4657)

Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.

Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
  canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
  position then playback state (position-first so RSVP's paused handler
  can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
  no-op when no session exists.

RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
  (starts externally driven); gated on a live tts-playback-state signal
  so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.

Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
  node, fixing word-highlight drift on middle sentences of single-<span>
  paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
  not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 13:47:29 +08:00
committed by GitHub
parent 72233e1c6a
commit e327d0c992
16 changed files with 496 additions and 6 deletions
@@ -45,6 +45,7 @@ vi.mock('@/store/readerStore', () => {
sectionHref: `ch${primaryIndex}.xhtml`,
}),
getViewSettings: () => null,
getViewState: () => null,
};
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
@@ -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: <R,>(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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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<RSVPControlHandle>();
render(
@@ -359,6 +359,59 @@ describe('TTS', () => {
});
});
describe('from() selection start', () => {
// A paragraph whose whole text is one text node (one <span>), so every
// sentence after the first is a mid-node sub-range — the case where the
// annotation "Speak from selection" misfired.
const SENTENCE_DOC =
'<p><span>First sentence here. Those immortals are going crazy. Last one.</span></p>';
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('<p>Test content</p>', { lang: 'en' });
@@ -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(<Harness />);
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<typeof vi.fn> };
};
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;
@@ -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)', () => {
@@ -827,6 +827,85 @@ describe('TTSController', () => {
});
});
describe('redispatchPosition', () => {
const makeSentenceRange = () => {
document.body.innerHTML = '<p>Hello brave world</p>';
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);
@@ -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
// <span>): 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 =
'<p><span>First sentence. Second sentence. Third sentence.</span></p>';
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 =
'<p><span>First sentence here. Those immortals are going crazy. Last one.</span></p>';
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(
@@ -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 = () => {
@@ -260,6 +260,7 @@ const RSVPControl = forwardRef<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(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<RSVPControlHandle, RSVPControlProps>(function RSV
const choice = (e as CustomEvent<RsvpStartChoice>).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);
@@ -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) {
@@ -55,6 +55,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const previousSectionLabelRef = useRef<string | undefined>(undefined);
const ttsControllerRef = useRef<TTSController | null>(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<TTSController | null>(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;
@@ -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.
@@ -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.
@@ -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 <span>) 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 = '';
+41
View File
@@ -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) =>