From 4527aa277a8a4d1ce628e5e23e19be8288126cf6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 6 Jul 2026 13:56:51 +0900 Subject: [PATCH] feat(reader): add TTS speak button to dictionary popup (#4876) (#4957) Add a speaker button to the dictionary popup/sheet header that pronounces the current headword. Tapping it speaks via Edge TTS, falling back to the platform speech engine (Web Speech on desktop/web, native on the mobile app) when Edge is unavailable. To speak as soon as possible, a dedicated wordPronouncer bypasses the reader's TTSController entirely: it never runs EdgeTTSClient.init() (which wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly (whose static MP3 cache makes repeat words instant), and schedules one chunk on a dedicated Web Audio context isolated from any active read-aloud session. The context is warmed synchronously inside the click gesture so playback is not blocked by autoplay policy after the network await. Co-authored-by: Claude Opus 4.8 (1M context) --- .../reader/annotator/DictionarySheet.test.tsx | 27 +++ .../services/tts/wordPronouncer.test.ts | 184 +++++++++++++++++ .../components/annotator/DictionaryPopup.tsx | 2 + .../annotator/DictionaryResultsView.tsx | 64 +++++- .../components/annotator/DictionarySheet.tsx | 2 + .../src/services/tts/wordPronouncer.ts | 188 ++++++++++++++++++ 6 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts create mode 100644 apps/readest-app/src/services/tts/wordPronouncer.ts diff --git a/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx b/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx index de2a087d..86b2642e 100644 --- a/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx +++ b/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx @@ -69,6 +69,14 @@ vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s, })); +// The word pronouncer talks to Edge/WebAudio; stub it so the sheet's speak +// button can be exercised without real audio. (#4876) +vi.mock('@/services/tts/wordPronouncer', () => ({ + pronounceWord: vi.fn().mockResolvedValue(undefined), + warmWordAudio: vi.fn(), + cancelWordPronounce: vi.fn(), +})); + const mockOpenUrl = vi.fn().mockResolvedValue(undefined); vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (...args: unknown[]) => mockOpenUrl(...args), @@ -217,6 +225,7 @@ const buildExactProvider = (storedHeadword: string): DictionaryProvider => { // --------------------------------------------------------------------------- import DictionarySheet from '@/app/reader/components/annotator/DictionarySheet'; +import { pronounceWord, warmWordAudio } from '@/services/tts/wordPronouncer'; const renderSheet = ( props: Partial<{ @@ -271,6 +280,24 @@ describe('DictionarySheet — header', () => { }); }); +describe('DictionarySheet — speak button', () => { + it('warms audio and pronounces the current word when the speaker is tapped', async () => { + providersForNextRender.push(buildRealStarDictProvider()); + renderSheet({ word: 'hello', lang: 'en' }); + + const speak = await screen.findByLabelText('Speak'); + fireEvent.click(speak); + + expect(warmWordAudio).toHaveBeenCalledTimes(1); + expect(pronounceWord).toHaveBeenCalledWith( + 'hello', + 'en', + expect.any(Object), + expect.any(Function), + ); + }); +}); + describe('DictionarySheet — concurrent lookup', () => { it('fans out lookups across every enabled definition provider', async () => { const stardict = buildRealStarDictProvider(); diff --git a/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts b/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts new file mode 100644 index 00000000..5ccb19ea --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts @@ -0,0 +1,184 @@ +/** + * wordPronouncer — pronounces a single dictionary word. + * + * The pronouncer is deliberately independent of the reader's TTSController: it + * synthesizes via EdgeSpeechTTS directly (no throwaway init synth), plays on a + * dedicated Web Audio context, and drops to the platform speech client + * (Web Speech on desktop/web, native on the mobile app) when Edge is + * unavailable. These tests pin that Edge-first / fallback-on-failure contract + * and the language -> Edge voice selection. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { TTSUtils } from '@/services/tts/TTSUtils'; + +const h = vi.hoisted(() => { + const createAudioData = vi.fn(); + let sessionOnEvent: ((e: { type: string; message?: string }) => void) | null = null; + const player = { + ensureContext: vi.fn().mockResolvedValue({}), + decode: vi.fn().mockResolvedValue({ duration: 0.5 }), + startSession: vi.fn((onEvent: (e: { type: string; message?: string }) => void) => { + sessionOnEvent = onEvent; + return 1; + }), + scheduleChunk: vi.fn(), + endSession: vi.fn(), + abortSession: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + fireSessionEnd: () => sessionOnEvent?.({ type: 'session-end' }), + fireContextError: () => sessionOnEvent?.({ type: 'context-error', message: 'boom' }), + }; + const makeClient = (speak: unknown) => ({ + init: vi.fn().mockResolvedValue(true), + setPrimaryLang: vi.fn(), + speak, + shutdown: vi.fn().mockResolvedValue(undefined), + }); + // eslint-disable-next-line require-yield + const webSpeak = vi.fn(async function* (_ssml: string, _signal: AbortSignal) {}); + // eslint-disable-next-line require-yield + const nativeSpeak = vi.fn(async function* (_ssml: string, _signal: AbortSignal) {}); + return { + createAudioData, + player, + webClient: makeClient(webSpeak), + nativeClient: makeClient(nativeSpeak), + webSpeak, + nativeSpeak, + }; +}); + +vi.mock('@/libs/edgeTTS', () => { + class EdgeSpeechTTS { + static voices = [ + { name: 'Aria', id: 'en-US-AriaNeural', lang: 'en-US' }, + { name: 'Ryan', id: 'en-GB-RyanNeural', lang: 'en-GB' }, + { name: 'Denise', id: 'fr-FR-DeniseNeural', lang: 'fr-FR' }, + ]; + createAudioData = h.createAudioData; + } + return { EdgeSpeechTTS }; +}); + +vi.mock('@/services/tts/WebAudioPlayer', () => ({ + WebAudioPlayer: class { + constructor() { + Object.assign(this, h.player); + } + }, +})); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: class { + constructor() { + Object.assign(this, h.webClient); + } + }, +})); + +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: class { + constructor() { + Object.assign(this, h.nativeClient); + } + }, +})); + +import { pronounceWord, pickEdgeVoiceId, cancelWordPronounce } from '@/services/tts/wordPronouncer'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as unknown as { AudioContext: unknown }).AudioContext = vi.fn(); + h.createAudioData.mockReset(); +}); + +describe('pickEdgeVoiceId', () => { + it('returns the first Edge voice whose locale matches the language', () => { + expect(pickEdgeVoiceId('en')).toBe('en-US-AriaNeural'); + expect(pickEdgeVoiceId('fr')).toBe('fr-FR-DeniseNeural'); + }); + + it('falls back to the default English voice for an unknown language', () => { + expect(pickEdgeVoiceId('xx')).toBe('en-US-AriaNeural'); + }); + + it("respects the user's preferred Edge voice for the language when valid", () => { + const spy = vi.spyOn(TTSUtils, 'getPreferredVoice').mockReturnValue('en-GB-RyanNeural'); + expect(pickEdgeVoiceId('en')).toBe('en-GB-RyanNeural'); + spy.mockRestore(); + }); +}); + +describe('pronounceWord — Edge path', () => { + it('synthesizes with Edge and schedules playback without touching the fallback', async () => { + h.createAudioData.mockResolvedValue({ data: new ArrayBuffer(8), boundaries: [] }); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + + expect(h.createAudioData).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', lang: 'en', voice: 'en-US-AriaNeural' }), + ); + expect(h.player.scheduleChunk).toHaveBeenCalledTimes(1); + expect(h.webSpeak).not.toHaveBeenCalled(); + expect(h.nativeSpeak).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('playing'); + + h.player.fireSessionEnd(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('reports an error when the audio context surfaces one mid-playback', async () => { + h.createAudioData.mockResolvedValue({ data: new ArrayBuffer(8), boundaries: [] }); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + h.player.fireContextError(); + + expect(onStatus).toHaveBeenLastCalledWith('error'); + }); +}); + +describe('pronounceWord — fallback path', () => { + it('drops to Web Speech on desktop/web when Edge fails', async () => { + h.createAudioData.mockRejectedValue(new Error('wss blocked')); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + await flush(); + + expect(h.webSpeak).toHaveBeenCalledTimes(1); + const ssml = h.webSpeak.mock.calls[0]![0]; + expect(ssml).toContain('hello'); + expect(h.nativeSpeak).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('uses the native client on the mobile app when Edge fails', async () => { + h.createAudioData.mockRejectedValue(new Error('wss blocked')); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', { appService: { isMobile: true } as never }, onStatus); + await flush(); + + expect(h.nativeSpeak).toHaveBeenCalledTimes(1); + expect(h.webSpeak).not.toHaveBeenCalled(); + }); +}); + +describe('pronounceWord — guards', () => { + it('does nothing for a blank word', async () => { + const onStatus = vi.fn(); + await pronounceWord(' ', 'en', {}, onStatus); + expect(h.createAudioData).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('aborts the active session on cancel', async () => { + cancelWordPronounce(); + expect(h.player.abortSession).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/DictionaryPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/DictionaryPopup.tsx index 3c86583f..8da31af6 100644 --- a/apps/readest-app/src/app/reader/components/annotator/DictionaryPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/DictionaryPopup.tsx @@ -55,6 +55,8 @@ const DictionaryPopup: React.FC = ({ canGoBack={state.canGoBack} goBack={state.goBack} onManage={onManage} + onSpeak={state.speakWord} + speaking={state.isSpeaking} />
diff --git a/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx index 8c6ab7b1..591f686c 100644 --- a/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MdArrowBack, MdChevronRight, MdSettings } from 'react-icons/md'; +import { MdArrowBack, MdChevronRight, MdSettings, MdVolumeUp } from 'react-icons/md'; import clsx from 'clsx'; import { openUrl } from '@tauri-apps/plugin-opener'; @@ -12,6 +12,7 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; import { getEnabledProviders } from '@/services/dictionaries/registry'; import { buildLookupCandidates } from '@/services/dictionaries/lookupCandidates'; import { isTauriAppPlatform } from '@/services/environment'; +import { cancelWordPronounce, pronounceWord, warmWordAudio } from '@/services/tts/wordPronouncer'; import { getBuiltinWebSearch, substituteUrlTemplate, @@ -51,6 +52,10 @@ export interface DictionaryResultsState { noProvidersAtAll: boolean; /** Dictionary popup font-size multiplier (#4443); `1` = default sizes. */ fontScale: number; + /** Whether the current word is being fetched/spoken by the TTS engine (#4876). */ + isSpeaking: boolean; + /** Pronounce the current word via Edge TTS (falling back to platform speech). */ + speakWord: () => void; } /** @@ -124,6 +129,30 @@ export function useDictionaryResults({ setHistoryStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)); }, []); + // Pronounce the current headword (#4876). `isSpeaking` covers both the + // Edge fetch and playback so the header button can show one active state. + const [isSpeaking, setIsSpeaking] = useState(false); + const langCode = typeof lang === 'string' ? lang : Array.isArray(lang) ? lang[0] : undefined; + const speakWord = useCallback(() => { + // Warm the audio context synchronously inside the click gesture; the + // synth/play happens after a network await, outside the gesture window. + warmWordAudio(); + setIsSpeaking(true); + void pronounceWord(currentWord, langCode, { appService }, (status) => { + if (status !== 'playing') setIsSpeaking(false); + }); + }, [currentWord, langCode, appService]); + + // Stop any in-flight pronunciation when the word changes (in-content + // navigation / reopen) or the popup unmounts. + useEffect(() => { + return () => cancelWordPronounce(); + }, []); + useEffect(() => { + cancelWordPronounce(); + setIsSpeaking(false); + }, [currentWord]); + const toggleExpanded = useCallback((id: string) => { setCards((prev) => { const old = prev[id]; @@ -315,6 +344,8 @@ export function useDictionaryResults({ onWebSearchClickTauri, noProvidersAtAll, fontScale: settings.fontScale ?? 1, + isSpeaking, + speakWord, }; } @@ -324,6 +355,10 @@ interface DictionaryResultsHeaderProps { canGoBack: boolean; goBack: () => void; onManage?: () => void; + /** Pronounce the current word (#4876); omit to hide the speaker button. */ + onSpeak?: () => void; + /** Whether pronunciation is in progress, for the active button state. */ + speaking?: boolean; } export const DictionaryResultsHeader: React.FC = ({ @@ -332,6 +367,8 @@ export const DictionaryResultsHeader: React.FC = ( canGoBack, goBack, onManage, + onSpeak, + speaking, }) => { const _ = useTranslation(); return ( @@ -348,9 +385,28 @@ export const DictionaryResultsHeader: React.FC = ( ) : null}
- - {currentWord} - +
+ {onSpeak ? ( + + ) : null} + + {currentWord} + +
{onManage ? (