You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

iOS/iPadOS 26及macOS 26下SpeechSynthesis故障的版本检测与适配方案咨询

iOS/iPadOS 26及macOS 26下SpeechSynthesis故障的版本检测与适配方案咨询

根据你描述的这个SpeechSynthesis的棘手bug,我先梳理下已确认的影响范围,再针对版本检测和适配方案给出一些实用思路——毕竟Apple冻结User-Agent的操作确实给版本检测带来了不少麻烦。


一、已确认的Bug影响范围

先把你发现的信息整理得更清晰些,方便后续适配:

  • 受影响系统:iOS/iPadOS 26.x(包括26.0.1)、macOS 26
  • 受影响环境:所有调用Apple原生语音服务的浏览器(Safari + 第三方浏览器,因为它们共用系统级的SpeechSynthesis接口)
  • 受影响语音:不止中文/粤语语音(Tingting等),英文语音如Fred也会触发该bug
  • 触发条件:当合成文本**以半角<开头、半角>结尾,且中间包含非ASCII字符(如中文)**时,调用speak()后SpeechSynthesis会直接挂起,必须重启浏览器才能恢复功能

二、版本检测的可行方案(避开UA的坑)

因为Apple已经冻结了User-Agent字符串(新系统的UA会显示旧版本号,比如15.x),直接解析UA判断版本完全不可靠。这里推荐几个更靠谱的方案:

1. 基于Bug特征的检测(最推荐)

与其猜系统版本,不如直接检测当前环境是否存在这个bug——这是最准确的方式,能覆盖所有可能触发bug的版本,包括Apple后续可能推出的26.x小版本。
实现思路:

  • 页面初始化时,用一段静默的测试文本(比如<测试>)做一次无感知的测试
  • 通过监听合成事件或超时判断,检测调用speak()后是否能正常触发合成,如果不能则判定存在bug
  • 代码示例:
let hasSpeechSynthesisBug = false;

// 静默检测当前环境是否存在SpeechSynthesis bug
async function detectSpeechBug() {
  return new Promise((resolve) => {
    if (!window.speechSynthesis) {
      resolve(false);
      return;
    }

    // 获取任意一个系统自带的Apple语音(用于测试)
    const voices = window.speechSynthesis.getVoices();
    const testVoice = voices.find(
      v => v.voiceURI.includes('Apple') || v.lang.startsWith('zh-CN') || v.name === 'Fred'
    );
    if (!testVoice) {
      resolve(false);
      return;
    }

    // 构造测试用的触发文本
    const testUtterance = new SpeechSynthesisUtterance('<测试>');
    testUtterance.voice = testVoice;
    testUtterance.volume = 0; // 静音测试,不打扰用户
    testUtterance.rate = 1;

    let testTimeout = setTimeout(() => {
      // 超时未触发合成,判定存在bug
      hasSpeechSynthesisBug = true;
      window.speechSynthesis.cancel();
      resolve(true);
    }, 2000); // 2秒超时,可根据实际情况调整

    // 合成正常启动,说明无bug
    testUtterance.onstart = () => {
      clearTimeout(testTimeout);
      hasSpeechSynthesisBug = false;
      window.speechSynthesis.cancel();
      resolve(false);
    };

    // 合成直接报错,说明存在bug
    testUtterance.onerror = () => {
      clearTimeout(testTimeout);
      hasSpeechSynthesisBug = true;
      resolve(true);
    };

    window.speechSynthesis.cancel();
    window.speechSynthesis.speak(testUtterance);
  });
}

2. 结合系统特性的 fallback 检测(辅助)

如果需要结合版本做兜底,可以尝试以下方式,但注意这些方法不是100%可靠:

  • iOS/iPadOS:可以通过window.visualViewport的特性或者navigator.maxTouchPoints辅助判断,但这些可能随系统更新变化
  • macOS:可以检测navigator.platform,但同样,冻结UA的系统版本可能不会更新这个值

3. 基于已知受影响版本的范围判断

如果已知bug仅存在于26.x版本,且iOS 18+、macOS 18+已修复,可以这么处理:

  • 若UA能解析到真实版本号(旧系统),则判断是否在26.x范围内
  • 若UA是冻结状态(显示旧版本,如15.x),则默认启用workaround(因为冻结UA的系统版本都较新,大概率包含26.x)

三、适配方案的优化

你demo中实现的将半角<>替换为全角<>\uff1c\uff1e)的方案是完全有效的,这里补充几个优化点:

  • 仅在检测到bug存在时才执行替换,避免对正常环境做不必要的字符转换
  • 可以维护一个替换映射表,方便后续扩展其他可能触发bug的符号
  • 替换时注意保留原文本的视觉一致性,全角尖括号和半角的视觉差异极小,用户基本感知不到

整合检测与适配的完整代码(基于你的demo修改)

let hasSpeechSynthesisBug = false;
let zhCnVoices = [];

// 静默检测bug
async function detectSpeechBug() {
  // 此处复用上面的detectSpeechBug函数实现即可
}

const init = async () => {
  if (document.querySelector('button')) return;
  
  // 等待 voices 加载完成
  await new Promise(resolve => {
    if (window.speechSynthesis.getVoices().length) resolve();
    window.speechSynthesis.addEventListener("voiceschanged", resolve, { once: true });
  });

  const voices = window.speechSynthesis.getVoices();
  zhCnVoices = [...voices].filter((voice, index, list) => 
    /^zh-CN/.test(voice.lang) && list.findIndex(that => that.voiceURI === voice.voiceURI) === index
  );
  if (!zhCnVoices.length) return;

  // 检测bug是否存在
  await detectSpeechBug();

  // 构建UI(和你的demo一致)
  const select = document.body.appendChild(document.createElement('select'));
  zhCnVoices.forEach(voice => {
    const option = select.appendChild(document.createElement('option'));
    option.textContent = voice.name;
    option.value = voice.voiceURI;
  });
  select.value = zhCnVoices.find(voice => /Tingting/.test(voice.voiceURI))?.voiceURI;

  const input = document.body.appendChild(document.createElement('input'));
  input.value = '<天地玄黄,宇宙鸿荒>';

  // 优化后的speak函数,根据bug检测结果决定是否替换符号
  const speak = (text) => {
    let processedText = text;
    // 仅在存在bug时执行替换
    if (hasSpeechSynthesisBug) {
      processedText = text.replace(/[<>]/g, ch => ({
        '<': '\uff1c',
        '>': '\uff1e'
      }[ch]));
    }
    const ssu = new SpeechSynthesisUtterance(processedText);
    ssu.voice = zhCnVoices.find(voice => voice.voiceURI === select.value);
    window.speechSynthesis.cancel();
    window.speechSynthesis.speak(ssu);
  };

  const button = document.body.appendChild(document.createElement('button'));
  button.textContent = 'Buggy Speak(直接用原文本)';
  button.addEventListener('click', () => {
    speak(input.value);
  });

  const button2 = document.body.appendChild(document.createElement('button'));
  button2.textContent = 'Workaround Speak(自动适配)';
  button2.addEventListener('click', () => {
    // 这里直接调用speak,内部会自动判断是否替换
    speak(input.value);
  });
};

window.speechSynthesis.addEventListener("voiceschanged", init);
init();

四、补充说明

  • 这个bug是系统级的,只要调用Apple的原生语音服务就会触发,所以必须在页面代码中做适配,无法依赖浏览器修复
  • 如果你想向Apple反馈这个bug,可以通过Apple Developer的Bug Reporter提交,附上你的复现代码和详细步骤,有助于Apple更快定位修复
  • 后续如果发现其他触发条件或者bug范围变化,随时调整检测逻辑即可

如果还有关于检测逻辑或者适配的细节问题,随时提出来,我再帮你调整~

火山引擎 最新活动