做在线阅读类产品的人,大概率都被人问过这么一句话:“能不能让它自己读出来?” 一旦这个需求落地,浏览器端文本转语音就成了绕不开的活儿。前端圈子里的默认答案通常是speechSynthesis.speak(),两行代码搞定,但真正上线之后你会发现:不同系统上音色完全不一样、语速没法定制、拿不到音频数据、想缓存不行、想接字幕对齐更不行。于是很多人开始找第二条路,Edge TTS 这类轻量级浏览器端文本转语音库就进入了视野——它不依赖本机语音引擎,而是直接拿到真正的声音数据流,交给你自己控制播放、缓存和字幕同步。我前后在两个项目里用过这条链路,也踩了不少坑,这篇就把它的工作原理、播放方案选型、SSML 调参和线上排错链路完整拆一遍,适合已经写过前端、想把语音功能做扎实的开发者看,也适合刚接触这个方向、想知道“为什么不直接用浏览器自带朗读”的同学。
1. 浏览器自带的朗读,究竟卡在哪一步
1.1 音色、跨端一致与音频数据所有权
speechSynthesis的设计初衷是“让网页能念字”,不是“让网页能生产音频”。这个定位差异决定了它天生缺三样东西,而这三样恰恰是产品化时最要命的。
第一是跨端一致性。同一个页面,在桌面端某个浏览器里是一把成熟女声,换到某个移动端就变成机械男声,语速、停顿、数字读法全变。你写好的换气节奏、标点停顿,在别人机器上完全是另一回事。对做有声内容、儿童读物、语言学习的项目来说,这种不可控是致命的。
第二是音频数据的所有权。speechSynthesis只负责“播”,播完就没了。你拿不到 PCM,也拿不到 MP3,没法存到本地缓存里,没法做波形图,没法把朗读和文字高亮精确对齐。想实现“读到哪里高亮到哪里”,只能靠onboundary事件,而这个事件在各端的支持度和触发粒度差异极大,中文场景下经常直接不触发。
第三是参数颗粒度。音高、语速、音量三个旋钮,能调的区间很窄,而且没有 SSML 那种“在这句话中间插 300 毫秒停顿”“把这个数字按电话号码读”的能力。做播客类文本、法规条文、化学式朗读时,这种粗糙度会直接把可用性拉低。
Edge TTS 这一类库解决的正是这三点:它把文本送到在线的语音合成服务,拿回来的是实实在在的音频字节流,你可以自己决定怎么播、播多久、存不存、要不要做淡入淡出。控制权回到了前端手里。
1.2 这类库真正提供的能力边界
先把预期摆正。轻量级浏览器端 TTS 库不是“把一整套语音引擎塞进浏览器”,它本质上是一个协议客户端:负责拼装请求、维持连接、解析回来的二进制分片、把音频块吐给上层。它自己不训练模型,也不做声学处理。
所以它提供的能力大致是这些:
- 一份可选的音色清单,通常几十到上百个,覆盖不同语言、性别、风格;
- 通过 SSML 控制语速、音高、音量、停顿、读音规则;
- 以流的方式逐块返回音频,首块到达就能开始播;
- 输出格式可选,常见是 24kHz 单声道 MP3,也有更低码率或 Opus 的选项。
它不提供的是:离线可用(本质仍要联网)、情感克隆、超低延迟的实时对话级响应(那是另一条技术路线的事)、以及对音频做后处理的工具。把这些边界想清楚,后面选型和排错才不会错方向。
1.3 什么场景值得上,什么场景别折腾
我的判断标准很朴素:如果朗读内容是"一次生成、多次播放"或者"需要和其它 UI 精确配合",就值得上;如果只是给一个临时提示语加个声音,别折腾。
值得上的典型场景:长文阅读器的整章朗读、课程讲稿的音频化、字幕逐句高亮跟读、客服话术的试听预览、多语言产品的发音示范。这些场景里,音频缓存能显著降低重复请求,音色一致性直接影响体验,音频数据本身也有二次利用价值。
不值得上的场景:一句“操作成功”的提示音、表单校验失败的提醒、加载中的占位音。这些用本地speechSynthesis或者干脆预录几个音频文件就够了,为了两秒钟的提示去建立一条网络连接,纯属给自己找事。
还有一种情况要谨慎:纯离线环境。如果产品要跑在内网、要保证断网可用,那这条在线链路只能当增强项,主体必须走本地合成或预生成音频,这一点在第 6 章会详细说。
2. 一次合成请求在链路上到底走了哪些环节
2.1 配置帧、音频帧与结束帧的分工
很多人第一次看这类库的源码会懵:明明只发了一段文字,为什么收到一大堆二进制块?因为这条链路是基于长连接的流式协议,一次合成会话里会来回传好几类消息。
大致分三类:
配置消息(发出去的):一段 JSON 或者 SSML,告诉服务端“我要用哪个音色、什么语速、什么输出格式、这段文本是什么”。通常还会带一个会话标识,让服务端知道后续的音频块属于哪次请求。
音频帧(收回来的):真正的声音数据,被切成很多小块陆续推过来。每一帧都带自己的头部,标明这帧是什么类型、属于哪个请求。
结束帧:一个明确的“本次合成到此为止”的信号。这个信号极其重要——很多新手写的代码靠“多久没收到数据”来判断结束,结果遇到长句子就被误判成结束,音频被截断半句话。
用生活化的类比:这就像你去餐厅点菜,配置消息是点单,音频帧是一道道端上来的菜,结束帧是服务员说的“您的菜上齐了”。如果你不等“上齐了”就开始结账走人,最后一道菜就留在厨房了。
2.2 二进制帧的头部结构,以及一段能直接用的解析代码
音频帧不是裸的音频字节,每帧前面都有一段文本头部。格式大致是:前 2 个字节(大端 uint16)表示头部总长度,紧接着是这个长度的头部文本,头部之后才是音频数据。
头部文本是\r\n分隔的键值对,常见字段包括请求标识、时间戳、路径(标明这帧是会话开始、音频数据还是会话结束)、内容类型。解析逻辑不复杂,但有几个细节容易翻车:
function parseFrame(buffer) { const view = new DataView(buffer); // 前两个字节是头部长度,大端序 const headerLength = view.getUint16(0, false); const headerBytes = new Uint8Array(buffer, 2, headerLength - 2); const headerText = new TextDecoder('utf-8').decode(headerBytes); const headers = {}; headerText.split('\r\n').forEach((line) => { if (!line) return; const idx = line.indexOf(':'); if (idx === -1) return; headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); }); const body = new Uint8Array(buffer, headerLength); return { headers, body }; }三个必须注意的点:
第一,getUint16(0, false)的第二个参数是littleEndian,必须传false,否则长度会读成一个天文数字,后面的slice直接越界。
第二,头部长度的语义是包含那两个长度字节本身,所以取头部文本时要2偏移、长度减2。这个细节不同实现有差异,接的时候拿一帧真实数据打印一下headerLength和文本长度对一下就能确认。
第三,body用Uint8Array的视图方式创建,不要slice复制。一帧可能几十 KB,长文本几十上百帧,无谓的复制会明显增加内存压力和 GC 频率。
2.3 音频格式选型:MP3 还是 Opus,24kHz 够不够
输出格式不是随便选的,它直接决定后面能不能用流式播放方案。
常见选项里,24kHz / 48kbps 单声道 MP3是最省心的:码率低、体积小、解码兼容性极好、可以直接拼接到一起。缺点是压缩有损,做音乐类内容不合适,但人声朗读完全够用。
Opus 封装在 WebM 里体积更小、延迟更低,某些场景音质更好,但浏览器端的 MSE 支持情况和拼接逻辑都更复杂,MP3 那种“帧序列直接拼”的便利没有了。
24kHz 采样率对语音来说是完全够的。人声主要能量集中在 300Hz 到 3.4kHz,电话音质是 8kHz 采样,宽带语音是 16kHz,24kHz 已经覆盖到 12kHz 的频带,齿音和气息细节都在。追求 48kHz 除了让文件大一倍,对人声朗读几乎没有感知收益。
顺带算一笔账:48kbps 意味着每秒 6KB,一分钟约 360KB,十分钟约 3.6MB。一章两万字的中文,正常语速大概 40 到 50 分钟,音频大约 15MB 到 18MB。这个量级放在 IndexedDB 里存几十章完全没问题,但别想着全塞进内存。
2.4 长文本到底在哪一层切分
这是个容易被忽略的架构问题。文本切分可以在三个地方做:前端发请求之前、协议层内部、或者干脆不切。
不切的风险很直接:一次请求几万字,服务端处理时间长,首字节要等好几秒甚至十几秒;中途一旦失败,整段重来;音频数据一次性堆在内存里,移动端容易崩。
在协议层内部切,通常是把一整段文本放进一条配置消息里,让服务端按句自行分块推流。这种方式首字节延迟不错,但失败重试的粒度仍然是一整段。
我的建议是前端主动按语义边界切片,每一片单独建会话。切片规则不用太聪明,按句号、问号、感叹号、换行切就行,每片控制在 200 到 500 个字符。这个粒度的好处是:首片音频几百毫秒就能到,播放器可以立刻开始播;某一片失败只重试那一片;缓存也能按片做,用户重听某一段时直接命中。代价是会话建立次数变多,连接复用要自己管好,这点在第 5 章讲。
3. 把音频接进页面:三种播放方案的取舍
3.1 整段解码播放:最省心,也最容易被误解
最直观的做法是把所有分片收完,拼成一个完整的 ArrayBuffer,交给AudioContext.decodeAudioData()解码成 AudioBuffer,然后用AudioBufferSourceNode播。
async function playWhole(chunks) { const ctx = new AudioContext(); if (ctx.state === 'suspended') await ctx.resume(); const total = chunks.reduce((n, c) => n + c.byteLength, 0); const merged = new Uint8Array(total); let offset = 0; for (const c of chunks) { merged.set(new Uint8Array(c), offset); offset += c.byteLength; } const audioBuffer = await ctx.decodeAudioData(merged.buffer); const source = ctx.createBufferSource(); source.buffer = audioBuffer; source.connect(ctx.destination); source.start(); }这条路的好处是控制力最强:拿到 AudioBuffer 之后,你能做可视化波形、能做任意位置的 seek、能加淡入淡出、能做多轨混音,暂停恢复只是记一下播放位置的事。
但它的误解在于“省心”。很多人以为拼起来就能解,实际上有几个前提:MP3 是帧序列格式,直接首尾相接一般能解,但如果你在分片之间插了别的字节,或者收到了不完整的尾帧,解码就会失败而且报错信息很含糊。另外decodeAudioData对大 buffer 是有压力的,我实测过十几 MB 的音频在某些移动端浏览器上会直接抛错或者卡住主线程好几百毫秒。
所以它的适用边界很清楚:短音频、需要精确控制、不在乎首字延迟。比如逐句朗读、单词发音、短提示语。整章朗读用它,用户的等待体验会很差。
3.2 MSE 流式播放:首字延迟最低,但格式支持是个坑
想让第一块音频到了就出声,标准答案是 Media Source Extensions。思路是把audio元素的src指向一个MediaSource对象,然后不断往 SourceBuffer 里appendBuffer。
const mediaSource = new MediaSource(); const audio = document.createElement('audio'); audio.src = URL.createObjectURL(mediaSource); mediaSource.addEventListener('sourceopen', () => { const mime = 'audio/mpeg'; if (!MediaSource.isTypeSupported(mime)) { console.warn('当前环境不支持该 MIME,走降级方案'); return; } const sb = mediaSource.addSourceBuffer(mime); // 注意:同一时刻只能有一个 appendBuffer 在进行 queue.forEach((chunk) => appendWhenReady(sb, chunk)); }); function appendWhenReady(sb, chunk) { if (sb.updating) { sb.addEventListener('updateend', () => appendWhenReady(sb, chunk), { once: true }); return; } sb.appendBuffer(chunk); }坑就在这里。audio/mpeg在 MSE 里的支持情况并不统一,桌面端某些内核支持良好,移动端某些浏览器完全不支持,Safari 系对 MSE 的音频容器偏好是audio/mp4。你要是硬上audio/mpeg,在某些机型上isTypeSupported返回 false,页面直接静音,而且控制台可能什么错都不报。
另外appendBuffer有并发限制:SourceBuffer 正在更新时再调用会抛InvalidStateError。必须用一个队列串行化,等updateend再塞下一块。这个队列如果写得不严谨,表现就是“播了前几秒就卡住不动了”。
我自己的做法是:先检测,检测不过直接不启用 MSE 路径,不要试图用 try-catch 硬扛。检测代码三行,比线上排查三天划算。
3.3 按句切片加 Blob 排队:兼容性最好的保底方案
如果 MSE 不可用,或者你干脆不想碰这套复杂度,还有一个很实用的中间方案:按句切片,每片单独请求,拿到完整音频后转成 Blob URL,用一个队列顺序播放。
class SentencePlayer { constructor() { this.queue = []; this.audio = new Audio(); this.playing = false; this.audio.addEventListener('ended', () => { this.playing = false; this.next(); }); } push(blobUrl) { this.queue.push(blobUrl); if (!this.playing) this.next(); } next() { const url = this.queue.shift(); if (!url) return; this.playing = true; this.audio.src = url; this.audio.play().catch(() => { this.playing = false; }); } }这个方案的精妙之处在于:每一片都是完整的、可独立播放的音频文件,不需要拼接、不需要 MSE、不需要格式协商。播放器天然支持,暂停、跳句、倍速全都能用。首字延迟取决于第一片什么时候到,因为切片小,通常几百毫秒就有声音。
代价是句子之间有极短的间隙(播放器切换 src 的开销),做连续朗读时能听出来一点点断。如果内容本身句间就该有停顿,这个“缺点”反而变成了优点。另外blob:URL 要记得在用过之后URL.revokeObjectURL(),不然一小时的朗读能攒出几百个未释放的 Blob,内存曲线会很难看。
3.4 三种方案放在一起对比
| 维度 | 整段解码 | MSE 流式 | 按句 Blob 排队 |
|---|---|---|---|
| 首字延迟 | 高(要等全部收完) | 最低 | 低(等第一句) |
| 兼容性 | 好 | 一般,格式依赖强 | 最好 |
| 播放控制力 | 最强(可 seek、可视化) | 中等 | 中等(可跳句) |
| 实现复杂度 | 低 | 高(队列 + 格式检测) | 低 |
| 句间断音 | 无 | 无 | 有极短间隙 |
| 适合场景 | 短音频、发音示范 | 长文连续朗读 | 通用、需要稳妥 |
实际项目里我的组合是:主路径走按句拼接加整段解码(缓存场景),实时朗读走按句 Blob 队列。MSE 只在明确验证过目标机型的项目里用,因为它的收益(省掉句间那几十毫秒)和它的维护成本不成正比。
4. 让机器读得像人:音色、语速和 SSML 的实操
4.1 音色清单怎么拿,怎么挑
音色不是硬编码在前端的字符串,正规做法是先请求一次清单接口,拿到可用音色的元数据:名称、语言地区、性别、风格标签、支持的内容类型。
清单一般不需要每次页面加载都拉。我的做法是把它缓存到 localStorage,设一个七天有效期,同时提供一个手动刷新的入口。清单体积不大,几十 KB,但请求本身有延迟,首屏就等它不合适。
挑音色这件事,光看名字是挑不出来的。列表里的描述只能告诉你“这是个中文女声”,但念法律条文和念儿童故事需要的音色完全不同。我的一般流程是:先按语言过滤,再按性别和风格标签缩小到三五个,然后写一段包含数字、英文缩写、长句、问句的测试文本,挨个听一遍。测试文本这点很重要,很多人拿一句“你好,欢迎使用”去试听,结果上线后发现念年份、念电话号码全是坑。
中文场景有几个常见音色 ID,比如zh-CN-XiaoxiaoNeural、zh-CN-YunxiNeural这类命名,前缀是语言地区,后缀是音色标识。英文、日文、粤语各有自己的一套。多语言产品里可以做语种自动路由:用正则或者轻量的语种判断,把文本片段分派给对应语言的音色,这样中英混排的内容读起来不会出现“英文单词被中文音色硬念”的尴尬。
4.2 prosody 三个参数的边界与叠加
SSML 里控制韵律的核心是<prosody>,三个属性:rate、pitch、volume。
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN"> <voice name="zh-CN-XiaoxiaoNeural"> <prosody rate="+15%" pitch="-2Hz" volume="+0%"> 这是一段带韵律控制的示例文本。 <break time="500ms"/> 停顿之后继续。 </prosody> </voice> </speak>rate支持百分比和关键词(如slow、fast)。实测下来,中文朗读的舒适区间是 -10% 到 +20%。超过 +30% 会出现明显的吞字,尤其是连续的多音节词;低于 -20% 会让韵律变得不自然,像在逐字念。做“1.5 倍速”这种功能时,不要直接把 rate 设成 +50%,音质会崩,更好的做法是用播放器的playbackRate做倍速,SSML 的 rate 只用来做微调。
pitch用赫兹或者百分比。女声往下调 2 到 4 Hz 会更沉稳,男声往上调同理但幅度要更小。超过 ±10 Hz 就会明显地“不像人”,听起来像被拉变调了。做儿童内容可以适当往上调,但要配合 rate 一起动,单动 pitch 会有塑料感。
volume是最没用的一个,因为播放器本身有音量控制,两者叠加容易失控。我的习惯是 SSML 里永远写+0%,音量全部交给播放器层,这样用户调音量时行为才可预期。
三者的叠加效应要注意:rate 提高 + pitch 提高,会让人声显得更急促更尖,不是简单的线性叠加。建议每次只调一个参数,听感确认后再动下一个。
4.3 多语言混读、标点停顿与数字读法
中文文本里夹英文术语是常态,比如“这个 API 的 timeout 默认是 30 秒”。不做处理的话,中文音色念英文单词会有很重的中式口音,念数字又可能读成“三十”而不是“三十秒”的节奏。
处理办法有两个层次。轻量的做法是在 SSML 里用<lang>标签或者给英文片段单独指定音色:
<voice name="zh-CN-XiaoxiaoNeural"> 这个 <voice name="en-US-JennyNeural">API</voice> 的 <voice name="en-US-JennyNeural">timeout</voice> 默认是 30 秒。 </voice>但嵌套 voice 标签的支持度和效果在不同服务上有差异,而且会让 SSML 变得很脆。我后来更倾向于前端先做语种切分:用正则把连续 ASCII 字母数字识别出来,把这些片段单独走英文音色请求,中文片段走中文音色,最后按顺序拼接播放。这样虽然多几次请求,但可控性高得多,缓存也更好做。
标点停顿是个容易被低估的细节。默认情况下引擎会自己按标点断句,但节奏未必符合你的预期。破折号、省略号、括号里的补充说明,处理规则各不相同。我的经验是:对破折号和省略号显式插<break time="300ms"/>到500ms,比依赖引擎自己判断可靠得多。列表项之间插 200ms 停顿,段落之间插 600ms 到 800ms,整篇听感的层次感会立刻出来。
数字和符号的读法可以用<say-as>指定。interpret-as="telephone"会把连续数字一个一个字念,适合电话号码;interpret-as="date"处理日期;interpret-as="currency"处理金额。不指定的话,“2024” 可能读成“两千零二十四”,也可能是“二零二四”,取决于上下文,这种不确定性必须靠显式标注消除。
4.4 文本预处理的四件必做的事
在把文本塞进 SSML 之前,有几步预处理我每次都会做,因为它们能省掉大量线上反馈。
清理非法字符。XML 有五个必须转义的字符:&、<、>、"、'。用户输入里带一个&,整个 SSML 就解析失败,请求直接报错。转义函数三行,但漏了就是全篇朗读失败。
合并多余空白。从富文本编辑器粘过来的内容经常带一堆不换行空格和零宽字符,这些在朗读时会变成莫名的长停顿,甚至让引擎读错。
处理 HTML 残留。如果内容来自富文本,<p>、<br>、 这些东西必须先剥掉再转义,顺序反了就会把转义后的实体又还原回去。
超长片段截断保护。单次请求的文本长度是有上限的,超了会直接失败。切片逻辑应该在预处理阶段就把超长段落按句切开,而不是等请求报错再补救。
5. 上线之后才暴露的问题:从现象到根因的排查链路
5.1 请求被拒和全程静音:先看请求头缺了什么
这是最典型的一类问题,现象是:本地开发完全正常,部署到正式环境后请求被拒,或者连接建立了但一个音频帧都收不到。
排查顺序我总结成三步。第一步,确认连接是否真的建立成功。有些环境里长连接被中间层直接重置,表现为连接状态短暂变化后立刻关闭。这时要看握手阶段返回的状态码,而不是只看业务层的回调。
第二步,逐字段核对请求头。这类服务对头部字段是有要求的,缺了某个字段不会给你明确提示,只会返回一个笼统的拒绝。常见的必需字段包括来源标识、版本标识、会话标识。有一回我遇到的就是版本标识写死了一个旧值,服务端升级后不再接受,改一下就好。
第三步,检查时间戳和签名。如果链路涉及签名,签名的有效期通常很短(几分钟),本地时钟偏差过大就会导致签名过期。这个问题的迷惑性在于它时好时坏——因为时钟偏差刚好在临界点附近。判断方法是打印出签名时间和本地时间对比。
我遇到过一次特别隐蔽的:本地开发用的是 http 本地地址,正式环境是 https 域名,来源标识字段在两种协议下的取值规则不同,导致正式环境被拒。这种问题看代码看不出来,只能对着网络面板逐个字段比。
5.2 首字延迟高:切片粒度、连接复用和预热
首字延迟是体验的分水岭。目标很明确:用户点了朗读,500 毫秒内要有声音。
影响它的因素按影响大小排:切片粒度 > 连接建立耗时 > 网络往返次数。
切片太粗是头号杀手。一段 2000 字的文本一次发过去,服务端要先把整段处理好才开始推流,这一等可能就是两三秒。切成 300 字一片,第一片几百毫秒就回来了。但切太细也不行,一句话拆成三段,请求次数翻三倍,总耗时反而更长。我实测下来 200 到 500 字是比较合适的甜点区,这个粒度和中文的自然段落长度也基本吻合。
连接建立耗时是第二大头。如果每一片都新建连接,握手就要花掉几百毫秒,纯属浪费。做法是维护一个连接池,或者至少复用同一条连接连续发多个请求。但要注意并发限制——同一个连接上并发太多请求会被限流,我的做法是串行发送,前一片的音频开始推流后就发下一片,用“预取一到两片”的方式平衡延迟和压力。
还有一个容易被忽视的技巧:用户点朗读之前就开始预热。比如在页面空闲时(requestIdleCallback)提前建立好连接,或者把第一片的请求提前发出去。当然这要看产品形态,读者打开文章页就自动开始朗读是打扰,但用户鼠标悬停在“朗读”按钮上时预热是合理的。
5.3 iOS 上的无声与播放状态异常
移动端的问题八成和自动播放策略有关。现象是:代码跑通了,音频数据也收到了,但就是没声音,控制台也没有明显报错。
根因是浏览器要求音频播放必须由用户手势触发。AudioContext创建出来默认是suspended状态,必须在一个真实的用户交互事件处理函数里调用resume()。注意是真实交互——setTimeout里调用不算,Promise.then里调用也可能不算。
button.addEventListener('click', async () => { if (ctx.state === 'suspended') { await ctx.resume(); } // 这里才开始真正合成和播放 startReading(); });另一个坑是手势的传递链会断。用户点了按钮,你开始异步请求音频,几百毫秒后数据回来才audio.play()——这时已经脱离了用户手势的上下文,某些浏览器会拒绝播放。解决办法是在点击的瞬间就调用一次play()播一个极短的静音音频,把播放权限“解锁”,后续的播放就都放行了。
还有个小细节:iOS 的静音开关会影响<audio>元素和 Web Audio 的输出行为,两者受影响的程度还不一样。测试时务必在真机上、把静音开关拨到静音档位试一遍——这个问题桌面模拟器永远测不出来。
5.4 长文本播放一小时后页面卡死
这是内存问题,症状很典型:短文本正常,连续朗读半小时后开始卡顿,一小时后标签页崩溃。
第一个泄漏点是 Blob URL。每片音频创建一个blob:URL,播放完不revoke,这些对象就一直挂在内存里,底层数据不会被回收。一小时朗读按每句 5 秒算,是 700 多个 URL,每个几十 KB,加起来就是几十 MB 的纯泄漏。
第二个泄漏点是音频缓冲区。整段解码方案里,AudioBuffer是实打实的内存占用,24kHz 单声道 float32,一秒就是 96KB,一小时接近 350MB。这个量级必然崩。所以长文本绝对不能用整段解码,必须用按句播放、播完即释放的模式。
第三个是事件监听。每次播放都addEventListener('ended', ...)而不移除,监听器会越堆越多,虽然单个监听器不占多少内存,但它闭包里引用的对象全都释放不掉。用{ once: true }或者显式removeEventListener就能解决。
排查这类问题,我一般用性能面板的内存快照,隔十分钟采一次,对比三次,看哪个类型的对象数量在持续增长。比盲猜快得多。同时可以观察performance.memory(如果可用)的走势,增长曲线是锯齿状上升说明每次都留了东西,直线上升说明是累积不释放。
6. 链路不通的时候,页面还得会说话
6.1 两级降级:在线合成、本地合成、明确的提示
任何依赖外部服务的功能都必须有降级路径,语音这种东西尤其明显——用户点朗读没反应,会觉得整个产品坏了。
我的降级链是三层的。第一层是在线合成,音质好、音色统一,是主路径。第二层是本地speechSynthesis,音质差、跨端不一致,但至少能出声,用户能知道功能存在。第三层是明确的 UI 提示,告诉用户当前环境不支持朗读,并给一个可选的操作建议。
切换逻辑要做得“安静”。不要弹窗说“在线合成失败,已切换到本地合成”,用户不关心这个。直接切就行,只是在控制台打一条日志,方便排查。但如果三层全挂,就必须显式告知,否则用户会一直点按钮。
触发降级的条件要写清楚:连接建立失败、连续两次请求超时、返回的音频帧数为零、解码失败。这几个条件都满足任一条就切。注意要加一个冷却期,比如切到降级后 5 分钟内不再尝试在线路径,否则每个句子都试一次失败一次,会把页面拖得很慢。
6.2 缓存和预生成:把重复的请求彻底省掉
朗读这个功能的请求模式非常规律:同一篇文章会被反复朗读,同一个音色参数会被反复使用。这就是天然的缓存场景。
我的做法是在文本切片之后、发请求之前,先算一个哈希,键是“文本内容 + 音色 ID + 语速参数 + 输出格式”。这个键命中 IndexedDB 就直接取音频,不请求。
async function getAudio(key, text, voice, rate) { const cached = await idbGet('tts-cache', key); if (cached) return cached; const chunks = await synthesize(text, voice, rate); const blob = new Blob(chunks, { type: 'audio/mpeg' }); await idbSet('tts-cache', key, blob); return blob; }几个实践细节:键里必须包含所有影响输出的参数,漏掉一个就会出现“改了语速但还是旧声音”的问题。要设容量上限,比如 200MB,超了按最后访问时间淘汰,不然磁盘会被慢慢吃满。要处理 QuotaExceededError,用户浏览器存储配额有限,写入失败时要静默降级成不缓存,而不是抛错中断播放。
更进一步的是预生成:对确定会被朗读的内容(比如热门文章的整章音频),在服务端提前生成好,前端直接当静态资源加载。这条路体验最好,成本也最高,适合内容相对固定的场景。
6.3 该埋哪些指标,才能在下一次故障前发现问题
语音功能的故障很隐蔽——用户不会报错,只会默默关掉页面。所以我在这块埋的埋点比一般功能密一些。
核心的几个:首字延迟(从点击到第一声的时间,P50 和 P95 分开看)、合成成功率(成功拿到音频帧的请求占比,按音色和切片长度分组)、降级触发率(这个数一涨就说明主链路出问题了)、缓存命中率(反映缓存键设计是否合理)、播放完成率(开始播放到自然结束的比例,低于某个阈值说明音频有截断)。
其中我最看重的是降级触发率和播放完成率。前者是主链路的健康度指示器,后者能抓到“能播但播不完”这种最隐蔽的问题。曾经有一次,某个音色的长句合成在特定参数下会少推最后一帧,接口返回成功、音频也能播,但最后半句话被吃掉。就是靠播放完成率异常发现的——那段时间完成率掉了 8%,追下去才定位到参数组合问题。
埋点本身要轻量,别为了统计把每帧数据都上报一遍。我一般是每次会话结束上报一条汇总,包含切片数、总耗时、失败片数、是否走降级。这个粒度足够定位问题,又不至于给日志系统添堵。
最后分享一个我踩过好几次才养成的习惯:在项目里内置一个“语音诊断页”。这个页面不需要好看,就几个按钮:测试连接、拉音色清单、合成一段固定文本、播放合成结果、显示每帧的头部信息。上线前自己点一遍,出问题时让用户打开这个页面点一遍,把控制台输出发过来。有了它,绝大多数“朗读没声音”的反馈能在五分钟内定位到是网络、参数、格式还是播放策略的问题,比来回问“你用的什么手机”高效太多。这个页面花半天写,能省下后面几个月的沟通成本。