基于 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 };
}

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐