背景
项目中需要实现文字播报功能,在 PC 和 iPhone 等设备上运行都很正常,但在一些国产 Android 手机上却出现问题:播放按钮点击无反应,语音播报功能不可用。经过排查发现,根本原因在于:
国产 Android 浏览器中
window.speechSynthesis为undefined即浏览器本身不支持 Web Speech API 的语音合成功能。
缺少关键 API,自然就无法使用原生的文字转语音能力。这一下踩了个"大坑"。 但俗话说:办法总比困难多。冷静喝口热茶后,我开始了兼容方案探索。
摸索解决方案
既然无法依赖浏览器原生能力,那么就将文本交给支持 TTS(Text-To-Speech)的云服务, 由云端生成 .wav / .mp3 音频,再进行播放。
然而我司暂无自建 TTS 服务,短期内只能先接第三方服务。
选择TTS服务
网上查询后发现,国内外都有 TTS 服务可用,
但国外接口调用可能需要科学上网,考虑到稳定性,优先选择国内厂商。
| 服务商 | 特点 | 
|---|---|
| 阿里云语音合成 | 中文质量高,可调节语速音量多音色 | 
| 科大讯飞 | 行业成熟,中文表现强 | 
| 百度智能云语音 | 神经网络合成,调用简单,文档充足 | 
| 腾讯云语音合成 | 支持多场景拟人语音 | 
我已有福报厂和某度账号,对比后挑了一个对个人用户提供的免费额度更充足的云TTS服务供应商。
某度TTS云服务注册使用方法
因某度 AI 控制台菜单繁多,为避免「找不到入口」,我整理了完整流程及关键页面:
1. 登录 某度大模型语音合成,点击立即选购
建议收藏这个入口,不然下次进入可能会迷路。 
2.进入某度智能云控制台完成实名认证
未认证将无法使用服务 
选择刷脸认证更快,填写的信息更少 
3. 进入语音技术页面
首次进入会显示创建应用提示

4.创建应用
应用名称随意填写,给应用起个好记的名称。描述简单填一下。服务接口全选即可。 
5. 获取API Key和Secret Key
应用创建成功后,会看到API Key和Secret Key, 这两个key是用来生成最终要使用的access_token

6. 点击发起调试,获取access_token
现在只想快速验证一下,并未按正常流程生成和维护access_token, 点击发起调试按钮后,会生成一个时效只有一天的访问凭证。后续上线需按正式流程获取 Token

7.调用验证
编写小页面测试是否可正常播放音频,写一个极简的调用某度TTS接口服务的程序,输入要播放的文字, 点击播放按钮,听一下有无声音发出。操作了一下,可以听到声音。
            
            
              html
              
              
            
          
          <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>TTS语音合成测试</title>
</head>
<body>
  <h3>TTS语音合成测试</h3>
  <textarea id="text" rows="4" cols="50" placeholder="请输入要合成的中文文本"></textarea>
  <br />
  <button id="playBtn">播放语音</button>
  <script>
    const ACCESS_TOKEN = "替换为你的_access_token";
    const API_URL = "https://tsn.baidu.com/text2audio";
    async function playBaiduTTS(text) {
      if (!text.trim()) {
        alert("请输入要合成的文本");
        return;
      }
      const params = new URLSearchParams({
        tex: text,
        tok: ACCESS_TOKEN,
        cuid: "test_user_" + Date.now(),
        ctp: "1",
        lan: "zh",
        spd: "5", // 语速
        pit: "5", // 音调
        vol: "5", // 音量
        per: "0", // 发音人
        aue: "3"  // MP3格式
      });
      const response = await fetch(`${API_URL}?${params.toString()}`);
      if (!response.ok) {
        alert("请求失败,请检查网络或token");
        return;
      }
      const contentType = response.headers.get("content-type");
      if (contentType.includes("application/json")) {
        const err = await response.json();
        alert("合成失败: " + JSON.stringify(err));
        return;
      }
      const blob = await response.blob();
      const audioUrl = URL.createObjectURL(blob);
      const audio = new Audio(audioUrl);
      audio.play();
    }
    document.getElementById("playBtn").onclick = () => {
      const text = document.getElementById("text").value;
      playBaiduTTS(text);
    };
  </script>
</body>
</html>
        8. 查看额度
使用第三方的TTS服务, 比较关心使用额度,这个菜单不是很好找, 为了避免超额停服,务必记得收藏查询入口 
兼容性最终解决方案
封装一个 TTS 播放 Hook,优先使用 Web 原生 SpeechSynthesis ,某度TTS作为降级方案
这样综合使用可最大化兼顾性能与兼容性。
| 指标 | 原生播报 | 某度 TTS | 
|---|---|---|
| 请求延迟 | ✅ 无 | ❌ 有网络开销 | 
| 播放速度体验 | ✅ 即点即播 | ⏳ 需要等待 | 
| 支持范围 | ❌ 国产安卓支持性差 | ✅ 兼容度高 | 
Hook 核心逻辑结构:
1. 初始化
- 
通过
isNativeSupported判断浏览器是否支持speechSynthesis。 - 
初始化内部状态:
isPlaying:当前是否有语音正在播放。utteranceRef:存储原生 TTS 的SpeechSynthesisUtterance实例。baiduAudioRef和baiduAudioUrlRef:存储某度 TTS 音频对象及其 URL。currentTextRef:记录当前播放的文本内容。
 
2. 播放语音 (play)
- 
停止当前播放:
- 如果正在播放原生 TTS 或某度音频,先调用 
stop()停止它们。 
 - 如果正在播放原生 TTS 或某度音频,先调用 
 - 
选择播放方式:
- 原生 TTS 支持 → 创建 
SpeechSynthesisUtterance实例播放。 - 原生 TTS 不支持 → 调用 
playBaiduTTS发起TTS 请求并播放返回音频。 
 - 原生 TTS 支持 → 创建 
 - 
状态更新:
isPlaying置为true。- 设置播放结束或出错时清理资源并更新状态。
 
 
3. 某度 TTS 播放 (playBaiduTTS)
- 
构造请求 URL(包含文本、语速、音调等参数)。
 - 
fetch请求某度 TTS API:- 如果返回 JSON → 出错处理。
 - 返回音频 Blob → 创建 
Audio对象并播放。 
 - 
注册事件:
onplay→ 更新isPlaying = trueonpause→ 更新isPlaying = falseonended→ 清理资源、重置状态onerror→ 提示播放失败,清理资源
 
4. 播放控制
- 
暂停 (
pause) :- 原生 TTS → 
window.speechSynthesis.pause() - 某度 TTS → 
audio.pause() 
 - 原生 TTS → 
 - 
继续 (
resume) :- 原生 TTS → 
window.speechSynthesis.resume() - 某度 TTS → 
audio.play() 
 - 原生 TTS → 
 - 
停止 (
stop) :- 原生 TTS → 
cancel()并清空utteranceRef - 某度 TTS → 暂停音频、释放 URL、清空 
baiduAudioRef 
 - 原生 TTS → 
 
5. 资源清理
- 某度 TTS 播放结束或组件卸载时,释放音频 URL 避免内存泄漏。
 - 原生 TTS 结束时清空 
utteranceRef。 
实现语音播报的完整代码如下:
            
            
              js
              
              
            
          
          import { useRef, useState, useCallback } from 'react';
import { message } from 'antd';
interface UseTTSOptions {
  baiduAccessToken?: string;
}
interface UseTTSReturn {
  isNativeSupported: boolean;
  isPlaying: boolean;
  play: (text: string) => Promise<void>;
  pause: () => void;
  resume: () => void;
  stop: () => void;
}
export const useTTS = ({ baiduAccessToken }: UseTTSOptions = {}): UseTTSReturn => {
  const isNativeSupported = typeof window !== 'undefined' && 'speechSynthesis' in window;
  const [isPlaying, setIsPlaying] = useState(false);
  const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
  const baiduAudioRef = useRef<HTMLAudioElement | null>(null);
  const baiduAudioUrlRef = useRef<string | null>(null);
  const currentTextRef = useRef<string | null>(null);
  const cleanupBaiduAudio = useCallback(() => {
    if (baiduAudioRef.current) {
      baiduAudioRef.current.pause();
      baiduAudioRef.current = null;
    }
    if (baiduAudioUrlRef.current) {
      URL.revokeObjectURL(baiduAudioUrlRef.current);
      baiduAudioUrlRef.current = null;
    }
    setIsPlaying(false);
  }, []);
  const stop = useCallback(() => {
    if (isNativeSupported) {
      window.speechSynthesis.cancel();
      utteranceRef.current = null;
    } else {
      cleanupBaiduAudio();
    }
  }, [isNativeSupported, cleanupBaiduAudio]);
  const playBaiduTTS = useCallback(
    async (text: string) => {
      if (!baiduAccessToken) {
        message.error('未提供TTS Access Token');
        return;
      }
      try {
        const params = new URLSearchParams({
          tex: text,
          tok: baiduAccessToken,
          cuid: `user_${Date.now()}`,
          ctp: '1',
          lan: 'zh',
          spd: '5',
          pit: '5',
          vol: '5',
          per: '0',
          aue: '3',
        });
        const response = await fetch(`https://tsn.baidu.com/text2audio?${params.toString()}`);
        if (!response.ok) throw new Error('TTS请求失败');
        const contentType = response.headers.get('content-type') || '';
        if (contentType.includes('application/json')) {
          const err = await response.json();
          throw new Error(err.err_msg || '语音合成失败');
        }
        const blob = await response.blob();
        const audioUrl = URL.createObjectURL(blob);
        if (baiduAudioUrlRef.current) URL.revokeObjectURL(baiduAudioUrlRef.current);
        baiduAudioUrlRef.current = audioUrl;
        const audio = new Audio(audioUrl);
        baiduAudioRef.current = audio;
        audio.onplay = () => setIsPlaying(true);
        audio.onpause = () => setIsPlaying(false);
        audio.onended = () => {
          setIsPlaying(false);
          currentTextRef.current = null;
          cleanupBaiduAudio();
        };
        audio.onerror = () => {
          message.error('音频播放失败');
          setIsPlaying(false);
          currentTextRef.current = null;
          cleanupBaiduAudio();
        };
        await audio.play();
      } catch (error: any) {
        console.error('TTS播放失败:', error);
        message.error('语音播放失败,请稍后重试');
        setIsPlaying(false);
        currentTextRef.current = null;
      }
    },
    [baiduAccessToken, cleanupBaiduAudio]
  );
  const play = useCallback(
    async (text: string) => {
      if (!text.trim()) return;
      stop();
      currentTextRef.current = text;
      if (isNativeSupported) {
        const utterance = new SpeechSynthesisUtterance(text);
        utterance.lang = 'zh-CN';
        utterance.volume = 1;
        utterance.rate = 1;
        utterance.pitch = 1;
        utterance.onend = () => {
          setIsPlaying(false);
          currentTextRef.current = null;
          utteranceRef.current = null;
        };
        utterance.onerror = () => {
          message.error('语音播放失败');
          setIsPlaying(false);
          currentTextRef.current = null;
          utteranceRef.current = null;
        };
        utteranceRef.current = utterance;
        window.speechSynthesis.speak(utterance);
        setIsPlaying(true);
      } else {
        await playBaiduTTS(text);
      }
    },
    [isNativeSupported, playBaiduTTS, stop]
  );
  const pause = useCallback(() => {
    if (isNativeSupported && window.speechSynthesis.speaking) {
      window.speechSynthesis.pause();
      setIsPlaying(false);
    } else if (baiduAudioRef.current && !baiduAudioRef.current.paused) {
      baiduAudioRef.current.pause();
    }
  }, [isNativeSupported]);
  const resume = useCallback(() => {
    if (isNativeSupported && utteranceRef.current) {
      window.speechSynthesis.resume();
      setIsPlaying(true);
    } else if (baiduAudioRef.current && baiduAudioRef.current.paused) {
      baiduAudioRef.current.play();
    }
  }, [isNativeSupported]);
  return { isNativeSupported, isPlaying, play, pause, resume, stop };
};
        调用方式:
            
            
              js
              
              
            
          
          import React from 'react';
import { Button } from 'antd';
import { useTTS } from './useTTS';
export const TTSDemo: React.FC = () => {
  const { play, pause, resume, stop, isPlaying, isNativeSupported } = useTTS({
    baiduAccessToken: 'TTSAccessToken',
  });
  return (
    <div>
      <p>当前TTS方式: {isNativeSupported ? '浏览器原生' : '某度TTS'}</p>
      <Button onClick={() => play('你好,欢迎使用语音合成')}>{isPlaying ? '重新播放' : '播放'}</Button>
      <Button onClick={pause}>暂停</Button>
      <Button onClick={resume}>继续</Button>
      <Button onClick={stop}>停止</Button>
    </div>
  );
};
        总结
通过本次 TTS 功能的兼容性改造,实现了原生能力优先、云服务兜底 的方案设计,不仅成功解决了国产 Android 浏览器中 speechSynthesis 不支持的问题,也大幅提升了整体用户体验。方案本身结构清晰、扩展性强,可在未来继续复用。
当前方案具备以下优势:
- 完整覆盖国产 Android 浏览器兼容性
 - 在支持原生能力的设备上优先使用,保证体验最优
 - 在不支持原生能力时自动降级到云端 TTS
 - 模块化封装,易维护、易迁移
 
更重要的是,当前实现已预留扩展空间:
一旦公司上线自研 TTS 服务,只需修改请求逻辑,即可无缝切换,无需改动上层业务逻辑。
这一方案有效平衡了性能与兼容性,为跨平台语音播报能力打下坚实基础。