import { useState, useEffect, useRef, useCallback } from 'react'; export interface SpeakOptions { rate?: number; // 语速 0.1-10, 默认 1 pitch?: number; // 音调 0-2, 默认 1 volume?: number; // 音量 0-1, 默认 1 voice?: SpeechSynthesisVoice; lang?: string; // 默认 zh-CN } export interface UseSpeechSynthesisReturn { isSpeaking: boolean; isSupported: boolean; isPaused: boolean; voices: SpeechSynthesisVoice[]; currentVoice: SpeechSynthesisVoice | null; speak: (text: string, options?: SpeakOptions) => void; stop: () => void; pause: () => void; resume: () => void; setVoice: (voice: SpeechSynthesisVoice) => void; } /** * 获取可用的中文语音列表 */ export function getChineseVoices(): SpeechSynthesisVoice[] { if (typeof window === 'undefined' || !window.speechSynthesis) { return []; } const voices = window.speechSynthesis.getVoices(); return voices.filter( (v) => v.lang.startsWith('zh-CN') || v.lang.startsWith('zh-TW') || v.lang.startsWith('zh-HK') || v.lang.startsWith('zh-'), ); } /** * 获取最佳中文语音 — 优先选择包含 "Xiaoxiao" 的 (自然度最高) */ export function getBestChineseVoice(): SpeechSynthesisVoice | null { const chineseVoices = getChineseVoices(); if (chineseVoices.length === 0) return null; // 优先匹配包含 "Xiaoxiao" 的语音 const xiaoxiao = chineseVoices.find((v) => v.name.includes('Xiaoxiao')); if (xiaoxiao) return xiaoxiao; // 其次匹配 "Yunxi"、"Xiaoyi" const yunxi = chineseVoices.find((v) => v.name.includes('Yunxi')); if (yunxi) return yunxi; const xiaoyi = chineseVoices.find((v) => v.name.includes('Xiaoyi')); if (xiaoyi) return xiaoyi; // fallback 到第一个中文语音 return chineseVoices[0]; } /** * 将长文本按句号、换行分割成段落,避免浏览器截断 */ function splitTextIntoChunks(text: string, maxChunkLength: number = 200): string[] { const chunks: string[] = []; // 按句号、感叹号、问号、换行分割 const sentences = text.split(/(?<=[。!?!?\n])/g); let current = ''; for (const sentence of sentences) { if (current.length + sentence.length > maxChunkLength && current.length > 0) { chunks.push(current.trim()); current = ''; } current += sentence; } if (current.trim()) { chunks.push(current.trim()); } return chunks.length > 0 ? chunks : [text]; } /** * 浏览器 Speech Synthesis TTS Hook * * 使用浏览器原生 Speech Synthesis API 进行文字转语音。 * - 中文语音自动优选 * - 长文本自动分段 * - Chrome 暂停 bug 规避(定期 resume) */ export function useSpeechSynthesis(): UseSpeechSynthesisReturn { const [isSpeaking, setIsSpeaking] = useState(false); const [isPaused, setIsPaused] = useState(false); const [voices, setVoices] = useState([]); const [currentVoice, setCurrentVoice] = useState(null); const utteranceRef = useRef(null); const chunksRef = useRef([]); const chunkIndexRef = useRef(0); const optionsRef = useRef({}); const resumeIntervalRef = useRef | null>(null); const isSupported = typeof window !== 'undefined' && 'speechSynthesis' in window; // 加载语音列表 useEffect(() => { if (!isSupported) return; const loadVoices = () => { const available = window.speechSynthesis.getVoices(); if (available.length > 0) { setVoices(available); // 自动选择最佳中文语音 const best = getBestChineseVoice(); if (best) { setCurrentVoice(best); } } }; loadVoices(); window.speechSynthesis.onvoiceschanged = loadVoices; return () => { window.speechSynthesis.onvoiceschanged = null; }; }, [isSupported]); // Chrome bug 规避:定期 resume 避免长时间不调用后暂停 useEffect(() => { if (!isSupported || !isSpeaking || isPaused) return; resumeIntervalRef.current = setInterval(() => { if (window.speechSynthesis.speaking && window.speechSynthesis.paused) { window.speechSynthesis.resume(); } }, 5000); return () => { if (resumeIntervalRef.current) { clearInterval(resumeIntervalRef.current); resumeIntervalRef.current = null; } }; }, [isSupported, isSpeaking, isPaused]); /** 朗读下一段 */ const speakNextChunk = useCallback(() => { const chunks = chunksRef.current; const idx = chunkIndexRef.current; if (idx >= chunks.length) { // 全部读完 setIsSpeaking(false); setIsPaused(false); utteranceRef.current = null; return; } const utterance = new SpeechSynthesisUtterance(chunks[idx]); const opts = optionsRef.current; utterance.rate = opts.rate ?? 1; utterance.pitch = opts.pitch ?? 1; utterance.volume = opts.volume ?? 1; utterance.lang = opts.lang ?? 'zh-CN'; if (opts.voice) { utterance.voice = opts.voice; } else if (currentVoice) { utterance.voice = currentVoice; } utterance.onend = () => { chunkIndexRef.current++; speakNextChunk(); }; utterance.onerror = (e) => { // 'canceled' 是正常取消,不报错 if (e.error !== 'canceled' && e.error !== 'interrupted') { console.warn('[TTS] SpeechSynthesis error:', e.error); } setIsSpeaking(false); setIsPaused(false); utteranceRef.current = null; }; utterance.onpause = () => setIsPaused(true); utterance.onresume = () => setIsPaused(false); utteranceRef.current = utterance; window.speechSynthesis.speak(utterance); }, [currentVoice]); /** 开始朗读 */ const speak = useCallback( (text: string, options?: SpeakOptions) => { if (!isSupported || !text.trim()) return; // 先停止当前朗读 window.speechSynthesis.cancel(); // 分段 const chunks = splitTextIntoChunks(text); chunksRef.current = chunks; chunkIndexRef.current = 0; optionsRef.current = options ?? {}; setIsSpeaking(true); setIsPaused(false); // 延迟一帧确保 cancel 生效 setTimeout(() => speakNextChunk(), 50); }, [isSupported, speakNextChunk], ); /** 停止朗读 */ const stop = useCallback(() => { if (!isSupported) { console.warn('[useSpeechSynthesis] stop: speechSynthesis not supported'); return; } window.speechSynthesis.cancel(); setIsSpeaking(false); setIsPaused(false); utteranceRef.current = null; chunksRef.current = []; chunkIndexRef.current = 0; }, [isSupported]); /** 暂停 */ const pause = useCallback(() => { if (!isSupported) return; if (isSpeaking && !isPaused) { window.speechSynthesis.pause(); setIsPaused(true); } }, [isSupported, isSpeaking, isPaused]); /** 恢复 */ const resume = useCallback(() => { if (!isSupported) return; if (isPaused) { window.speechSynthesis.resume(); setIsPaused(false); } }, [isSupported, isPaused]); /** 设置语音 */ const setVoice = useCallback((voice: SpeechSynthesisVoice) => { setCurrentVoice(voice); }, []); // 组件卸载时停止 useEffect(() => { return () => { if (isSupported) { window.speechSynthesis.cancel(); } if (resumeIntervalRef.current) { clearInterval(resumeIntervalRef.current); resumeIntervalRef.current = null; } }; }, [isSupported]); return { isSpeaking, isSupported, isPaused, voices, currentVoice, speak, stop, pause, resume, setVoice, }; }