基于 Web Speech API 的 React 文本转语音Hook 实践
·
基于 Web Speech API 的 React 文本转语音Hook 实践
本文介绍如何用自定义 Hook
useStreamTTS,在 React 项目中实现对长文本、知识型内容的流畅语音朗读,重点解决分句、语速、中文女声选择、流式拼接等实际问题。
背景与需求
在 AI 助手、知识问答、内容播报等场景中,常常需要将大模型返回的长文本(如解释、分析、分段说明)通过语音合成(TTS)朗读出来。理想的体验应当:
- 句子切分自然,朗读停顿合理
- 语速适中,内容清晰易懂
- 支持中文女声,听感亲切
- 能适配大模型流式返回、增量拼接的文本
useStreamTTS Hook 设计要点
1. 分句策略:只在标点断句,未完结片段缓冲
大模型流式返回的文本往往被拆成多段,不能简单每次都朗读新增片段,否则会出现“您好,”“我之前”“已经向您介绍过,”等碎片化朗读。
解决方案:
- 维护 buffer,每次只把“以标点结尾的完整短句”推入朗读队列,未完结片段缓存在 buffer,等下次补全。
- 只在“。!?!?,,;;”等标点处断句,保证每句朗读完整。
2. 语速与停顿优化
- 默认语速 rate 设为 1.2~1.3,适合中文知识型内容
- 每句朗读结束后仅 30ms 停顿,保证连贯不卡顿
3. 中文女声自动选择
- 自动筛选系统 TTS 里的中文女声(如“晓梅”“晓云”等),优先用亲切自然的声音
- 兼容不同浏览器的语音包
4. 流式拼接与队列管理
- 支持大模型流式增量返回,自动拼接、分句、顺序朗读
- 朗读队列先进先出,保证内容顺序
核心代码片段
function splitIntoSentencesWithBuffer(buffer: string, newText: string): { sentences: string[]; buffer: string } {
const all = buffer + newText;
const parts = all.split(/([。!?!?,,;;])/g);
const sentences: string[] = [];
let temp = '';
for (let i = 0; i < parts.length; i += 2) {
temp += parts[i];
if (parts[i + 1]) temp += parts[i + 1];
if (parts[i + 1] && /[。!?!?,,;;]/.test(parts[i + 1])) {
if (temp.trim()) sentences.push(temp.trim());
temp = '';
}
}
return { sentences, buffer: temp };
}
// 朗读队列管理
const playNextSentence = useRef(() => {
if (sentenceQueueRef.current.length === 0 || isPlayingRef.current) return;
const sentence = sentenceQueueRef.current.shift();
if (!sentence) return;
isPlayingRef.current = true;
setIsSpeaking(true);
const utterance = new SpeechSynthesisUtterance(sentence);
utterance.lang = lang;
utterance.rate = rate;
utterance.pitch = pitch;
utterance.volume = volume;
if (voiceRef.current) utterance.voice = voiceRef.current;
utterance.onend = () => {
isPlayingRef.current = false;
setIsSpeaking(false);
setTimeout(() => playNextSentence.current(), 30);
};
utterance.onerror = utterance.onend;
window.speechSynthesis.speak(utterance);
});
实际体验与效果
- 长文本:无论是多段 markdown、列表、解释,TTS 都能分句朗读,停顿自然
- 流式增量:大模型边生成边播报,用户体验极佳
- 中文女声:自动选择最合适的中文女声,听感更亲切
- 不卡顿:每句只在标点断句,未完结片段缓冲,朗读顺畅
应用场景
- AI 助手、知识问答、内容播报
- 语音阅读、无障碍辅助
- 需要流式 TTS 的各类 Web 应用
总结
useStreamTTS 让 React 项目轻松拥有“流式长文本自然朗读”能力,适合各类知识型、交互型场景。只需一行代码,即可获得媲美专业 TTS 产品的体验。
源码:
import { useEffect, useRef, useState } from 'react';
export type UseStreamTTSOptions = {
lang?: string;
rate?: number;
pitch?: number;
volume?: number;
enabled?: boolean;
};
type Message = {
role: string;
parts: { type: string; text?: string }[];
};
function extractAssistantText(messages: Message[]): string {
return messages
.filter((msg) => msg.role === 'assistant')
.flatMap((msg) => msg.parts.filter((p) => p.type === 'text' && p.text).map((p) => p.text!.trim()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
// 只在常用短句标点(逗号、句号、问号、感叹号、分号等)处断句,未完结片段缓存在 buffer
function splitIntoSentencesWithBuffer(buffer: string, newText: string): { sentences: string[]; buffer: string } {
const all = buffer + newText;
// 按标点分割,保留分隔符
const parts = all.split(/([。!?!?,,;;])/g);
const sentences: string[] = [];
let temp = '';
for (let i = 0; i < parts.length; i += 2) {
temp += parts[i];
if (parts[i + 1]) temp += parts[i + 1];
// 只要遇到标点就断句
if (parts[i + 1] && /[。!?!?,,;;]/.test(parts[i + 1])) {
if (temp.trim()) sentences.push(temp.trim());
temp = '';
}
}
// temp 里是未完结的片段,不能推入队列
return { sentences, buffer: temp };
}
// 获取中文女声
function getChineseFemaleVoice(): SpeechSynthesisVoice | null {
const allVoices = speechSynthesis.getVoices();
const zhVoices = allVoices.filter((v) => v.lang.startsWith('zh'));
return zhVoices[3] || null;
}
export function useStreamTTS(messages: Message[], options: UseStreamTTSOptions = {}) {
const { lang = 'zh-CN', rate = 1.2, pitch = 1, volume = 1, enabled = true } = options;
const lastTextRef = useRef('');
const [isSpeaking, setIsSpeaking] = useState(false);
const voiceRef = useRef<SpeechSynthesisVoice | null>(null);
const sentenceQueueRef = useRef<string[]>([]);
const isPlayingRef = useRef(false);
const bufferRef = useRef('');
// 播放队列中的句子
const playNextSentence = useRef(() => {
if (sentenceQueueRef.current.length === 0 || isPlayingRef.current) return;
const sentence = sentenceQueueRef.current.shift();
if (!sentence) return;
isPlayingRef.current = true;
setIsSpeaking(true);
const utterance = new SpeechSynthesisUtterance(sentence);
utterance.lang = lang;
utterance.rate = rate;
utterance.pitch = pitch;
utterance.volume = volume;
if (voiceRef.current) {
utterance.voice = voiceRef.current;
}
utterance.onend = () => {
isPlayingRef.current = false;
setIsSpeaking(false);
// 播放下一句
setTimeout(() => playNextSentence.current(), 30);
};
utterance.onerror = () => {
isPlayingRef.current = false;
setIsSpeaking(false);
// 播放下一句
setTimeout(() => playNextSentence.current(), 30);
};
window.speechSynthesis.speak(utterance);
});
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
// 获取并设置中文女声
if (!voiceRef.current) {
voiceRef.current = getChineseFemaleVoice();
}
const fullText = extractAssistantText(messages);
const prevText = lastTextRef.current;
// 只处理新增的文本
if (fullText !== prevText && fullText.length > prevText.length) {
const newText = fullText.slice(prevText.length);
// 用 bufferRef + 新增文本做分句
const { sentences: newSentences, buffer } = splitIntoSentencesWithBuffer(bufferRef.current, newText);
sentenceQueueRef.current.push(...newSentences);
bufferRef.current = buffer;
lastTextRef.current = fullText;
// 开始播放
playNextSentence.current();
}
}, [messages, lang, rate, pitch, volume, enabled]);
return { isSpeaking };
}
更多推荐


所有评论(0)