This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/frontend/web/src/hooks/useSpeechSynthesis.ts
T
AskaEth b15e1c9541 fix: 第二轮深度调试修复 - useSpeechSynthesis守卫+NPE防护+E2E测试完善
- 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
2026-05-22 00:10:37 +08:00

285 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}