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

背景

项目中需要实现文字播报功能,在 PC 和 iPhone 等设备上运行都很正常,但在一些国产 Android 手机上却出现问题:播放按钮点击无反应,语音播报功能不可用。经过排查发现,根本原因在于:

国产 Android 浏览器中 window.speechSynthesisundefined

即浏览器本身不支持 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 实例。
    • baiduAudioRefbaiduAudioUrlRef:存储某度 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 服务,只需修改请求逻辑,即可无缝切换,无需改动上层业务逻辑。

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

相关推荐
三年三月7 小时前
自建HTTPS证书
前端·javascript
木易士心7 小时前
如何优化v-if和v-for的性能?
前端·javascript
三年三月7 小时前
浏览器地址栏回车 vs 点击刷新按钮的缓存行为差异分析
前端·javascript
码农刚子7 小时前
ASP.NET Core Blazor 核心功能一:Blazor依赖注入与状态管理指南
前端·后端
胖虎2657 小时前
基于Vue3+xgplayer 移动端直播解决方案
前端
用户4099322502127 小时前
Vue 3模板如何通过编译三阶段实现从声明式语法到高效渲染的跨越
前端·ai编程·trae
小左OvO7 小时前
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
前端·javascript·vue.js
小左OvO7 小时前
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
前端·javascript·vue.js
IT_陈寒8 小时前
Vite 5新特性解析:10个提速技巧让你的开发效率翻倍 🚀
前端·人工智能·后端