Android手机不支持文字转语音window.speechSynthesis API,怎么办?

背景

项目中需要实现文字播报功能,在 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 支持 → 创建 SpeechSynthesisUtterance 实例播放。
    • 原生 TTS 不支持 → 调用 playBaiduTTS 发起TTS 请求并播放返回音频。
  • 状态更新:

    • isPlaying 置为 true。
    • 设置播放结束或出错时清理资源并更新状态。

3. 某度 TTS 播放 (playBaiduTTS)

  • 构造请求 URL(包含文本、语速、音调等参数)。

  • fetch 请求某度 TTS API:

    • 如果返回 JSON → 出错处理。
    • 返回音频 Blob → 创建 Audio 对象并播放。
  • 注册事件:

    • onplay → 更新 isPlaying = true
    • onpause → 更新 isPlaying = false
    • onended → 清理资源、重置状态
    • onerror → 提示播放失败,清理资源

4. 播放控制

  • 暂停 (pause) :

    • 原生 TTS → window.speechSynthesis.pause()
    • 某度 TTS → audio.pause()
  • 继续 (resume) :

    • 原生 TTS → window.speechSynthesis.resume()
    • 某度 TTS → audio.play()
  • 停止 (stop) :

    • 原生 TTS → cancel() 并清空 utteranceRef
    • 某度 TTS → 暂停音频、释放 URL、清空 baiduAudioRef

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 服务,只需修改请求逻辑,即可无缝切换,无需改动上层业务逻辑。

这一方案有效平衡了性能与兼容性,为跨平台语音播报能力打下坚实基础。

相关推荐
定栓4 小时前
Webpack迁到Vite第一节:模块系统迁移:require → import
运维·前端·webpack
小熊在忙fpga4 小时前
N32G430芯片的flash表示
前端·javascript·html
Data analyse4564 小时前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
todoitbo4 小时前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
会灭火的程序员5 小时前
SuperMap AgentX Server智能体接入WEB应用技术文档
前端·supermap·agentx·agentx server
CRMEB5 小时前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz5 小时前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋5 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5
雪芽蓝域zzs5 小时前
JS 项目转 TS 项目(Vue3 + 若依这类后台项目)完整改造清单
前端·vue.js