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:
@@ -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 = '';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: d5e197c3de...a7b5118c78
Reference in New Issue
Block a user