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范围变化,随时调整检测逻辑即可
如果还有关于检测逻辑或者适配的细节问题,随时提出来,我再帮你调整~




