b15e1c9541
- P0: useSpeechSynthesis.ts cancel()增加isSupported守卫 - P0: iot_provider.go 添加loader nil检查防止NPE panic - 新增CDP E2E v4测试脚本 14项全绿通过 - 生成第二轮修复报告 docs/debug/2026-05-21-round2-fixes.md
285 lines
7.6 KiB
TypeScript
285 lines
7.6 KiB
TypeScript
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<SpeechSynthesisVoice[]>([]);
|
||
const [currentVoice, setCurrentVoice] = useState<SpeechSynthesisVoice | null>(null);
|
||
|
||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||
const chunksRef = useRef<string[]>([]);
|
||
const chunkIndexRef = useRef(0);
|
||
const optionsRef = useRef<SpeakOptions>({});
|
||
const resumeIntervalRef = useRef<ReturnType<typeof setInterval> | 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,
|
||
};
|
||
}
|