浏览器为什么播不了 App 语音:一次 AMR 互通和策略模式改造
这次问题一开始看起来很像一个普通的资源加载失败:用户资料页有语音介绍,Web 上点播放,UI 转起来了,但没有声音,控制台里还有资源错误。更麻烦的是,QA 补充说同一条语音在 Android 和 iOS 之间能互通,只有 Web 不行;Web 录出来的语音,App 端也播不了。
这种问题如果只看 UI,很容易把方向带偏:是不是接口没返回?是不是 OSS 链接失效?是不是 CORS?是不是浏览器自动播放策略?真正往下查之后,结论反而很朴素:文件是 AMR,App 原生播放器能播,浏览器原生 Audio 不能播。
这篇文章整理的是这次排查背后的原理,以及我们最后怎么把播放、录音、库选型和后续替换口放到一套策略模式里。
先把问题从网络层拿下来
排查第一步先确认资源到底有没有拿到,再决定要不要改播放器。某个真实样本的结果很明确:
- HTTP 返回是 200,不是 404。
- CORS 允许跨域,不是浏览器因为跨域拦了响应。
- 响应头是
Content-Type: audio/AMR。 - 文件前几个字节是
23 21 41 4d 52 0a,也就是文本里的#!AMR\n。 ffprobe能识别成amr_nb,采样率 8000 Hz,mono,时长约 7 秒。- Chrome 里
audio.canPlayType('audio/amr')返回空字符串,直接new Audio(url).play()会报不支持的媒体源。
这里最关键的是文件头。RFC 4867 在 AMR 文件存储格式里定义了单声道 AMR 文件的 magic string:#!AMR\n;AMR-WB 则是 #!AMR-WB\n。也就是说,只要已经看到这个头,就不应该再把它当成“图片、普通 mp3、资源缺失或浏览器偶发网络问题”。
magic string 是文件开头的一小段标识。很多二进制格式都会用它告诉读取方“我是什么”。扩展名可以被省略或写错,HTTP
Content-Type也可能来自不准确的元数据,但文件头通常更接近真相。
浏览器能力也要单独确认。MDN 的 HTMLMediaElement.canPlayType() 文档说明,浏览器不能播放某种媒体类型时会返回空字符串;MDN 的音频编解码格式表里,AMR 在 Chrome browser 这一列也不是常规可用格式。它适合移动通信语音,不等于适合浏览器原生媒体栈。
这一步把问题从“请求失败”拿了下来:资源能拿到,跨域也没拦,失败点在浏览器无法解码 AMR。
AMR 到底是什么
AMR 是 Adaptive Multi-Rate,最早主要服务移动通信里的语音。它不是为了音乐,也不是为了浏览器网页里的通用音频播放设计的。RFC 4867 里对 AMR-NB 的描述很具体:采样率 8000 Hz,每 20 ms 一帧;AMR-WB 则是 16000 Hz,每 20 ms 一帧。
可以把一个最常见的 AMR-NB 文件想成这样:
- 文件头先写
#!AMR\n,告诉解码器这是 AMR-NB。 - 后面是一帧一帧的语音数据。
- 每帧约 20 ms。
- 解码器按帧把它还原成 PCM 采样,再送到播放设备。
PCM 可以理解为“已经展开的原始波形采样”。浏览器、系统声卡和很多音频处理 API 最终都要把压缩格式还原成 PCM 才能播放或处理。MP3、AAC、AMR 这类格式的差异,主要在“怎么压缩、怎么封装、浏览器是否自带解码器”。
App 能播 AMR,是因为 Android / iOS 可以调用系统或应用内的原生媒体能力。Android 这边甚至能直接录 AMR:MediaRecorder.OutputFormat 里有 AMR_NB / AMR_WB,MediaRecorder.AudioEncoder 里也有 AMR_NB / AMR_WB。移动端天然离 AMR 更近。
Web 不一样。浏览器原生 Audio 和 <audio> 只支持浏览器内置媒体栈能解的格式。MP3、AAC、Opus、WebM 这类比较常见;AMR 就不能默认假设可播。于是就出现了移动端互通、Web 不出声的断层。
为什么不能只改播放
这个问题表面是“播不了”,实际有两条链路:
- App 录音上传 AMR,Web 要能播。
- Web 录音上传后,App 也要能播。
如果只修第一条,Web 播 App 的历史 AMR 语音就好了;但 Web 继续用浏览器原生 MediaRecorder 录出 WebM 或其他格式,App 端仍然可能不能按历史语音介绍链路播放。这也是 QA 说“iOS 录音 Web 不能播,Web 录音 iOS 也不能播”的原因之一:两端不是同一种编解码约定。
所以播放和录音要一起看。
播放链路里,普通 MP3 / M4A / WAV 仍然可以交给浏览器原生 Audio。只有遇到 AMR,才需要走额外的解码器:下载或初始化资源,把 AMR 帧解成 PCM,再交给 Web Audio 或库内部的播放能力。
录音链路更麻烦。浏览器的 MediaRecorder 很方便,但它不保证产物是 AMR。Chrome 常见是 WebM/Opus;Safari 又可能是另一路封装。对一个已经有多年 App 历史数据和 App 播放链路的业务来说,Web 不能自己偷偷换协议。
这就要求 Web 录音时也要有 AMR encode 能力:从麦克风拿到声音,得到 PCM 或库内部采样,再编码成 AMR Blob 上传。否则只是把“Web 不能播 App”变成“App 不能播 Web”。
实现机制:把播放器做成策略
这类兼容问题最好不要散落在组件里。组件只应该知道“我要播放一段语音”“我要开始录音”“我要拿到录音 blob”,不应该知道底层到底是 new Audio()、AMR JS 解码器、WASM 解码器,还是未来的后端转码地址。
所以我们把语音介绍抽成了一层 facade:
interface VoicePlaybackController {
play(): Promise<void>
stop(): void
destroy(): void
}
interface VoicePlaybackStrategy {
name: string
createController(
source: string,
hooks: VoicePlaybackHooks,
): Promise<VoicePlaybackController>
}组件拿到的是统一的 VoicePlaybackController。至于它背后用哪条策略,由工具层判断:
if (looksLikeAmr(source)) {
return [amrStrategy, nativeAudioStrategy]
}
if (looksLikeNativeAudio(source)) {
return [nativeAudioStrategy]
}
return [nativeAudioStrategy, amrStrategy]这里的顺序很重要。
如果地址明确像 AMR,比如 .amr、/voice/xxx、voicea870... 这种历史语音 fid,就先走 AMR。最后仍然保留原生 fallback,是为了给未来后端调整格式留一点容错。
如果地址明确是 mp3 / m4a / wav / webm,就只走原生 Audio。不要把每个普通音频都拿去 AMR 库里试一遍。
如果地址没有扩展名,也不明显属于历史语音 fid,就先让浏览器试;浏览器报不支持,再尝试 AMR。
播放可以 fallback,因为每次播放前都还能换一个 controller。录音不能这么随意:用户按住说话之后,声音样本已经开始流动了,中途切换 encoder 就会丢掉这次录音。因此录音只在“初始化”和“能力探测”阶段尝试可用策略,一旦开始录,就不能把失败的 AMR 录音偷偷 fallback 成 WebM。
这也是代码里一个很重要的约束:普通 MediaRecorder 不能作为 AMR 录音失败后的兜底。它确实可以让 Web 自己录出声音,但会重新制造 App 不兼容的问题。
UI 状态也要跟着真实播放走
之前还有一个很容易被忽视的问题:UI 不能在“用户点了播放”时就直接进入 playing。如果底层 play() 实际失败了,界面上转圈或变成播放中,就是一个假状态。
修完之后,语音播放状态应该是:
- 用户点击,进入
loading。 - 创建播放器 controller。
- 调用
controller.play()。 - 只有
play()成功 resolve,才进入playing。 - 如果失败,回到 idle,并展示最小错误提示。
这个顺序看起来只是小细节,但它决定了用户看到的是“真的在播放”,还是“按钮动画在自嗨”。音频、视频、支付、上传这类能力都一样:UI 状态必须跟真实副作用对齐。
两条库路线怎么选
这次调研里主要看了两条路线。
第一条是 benz-amr-recorder。它的优势非常直接:同一个库同时覆盖 AMR 播放和 AMR 录音,README 里也明确写了可以从 URL / Blob 初始化 AMR 播放,可以录制 AMR,也可以拿到 AMR Blob。对这次互通问题来说,它能最快形成闭环。
缺点也很明显:项目维护不算活跃,依赖链也比较旧,包体不小。它是一个现实可用的短期方案,不是一个让人完全放心的长期底座。
第二条是 audiojs 这一组库。audio-decode 的方向很漂亮:用 JS/WASM 把各种音频格式解成原始采样,支持浏览器和 Node,也把 AMR 解码拆成独立 codec 包。它更现代,也更适合未来把 AMR 解码策略替换掉。
但 audio-encode 的支持列表里没有 AMR。也就是说,audiojs 路线目前能帮我们解决“Web 播 App AMR”,不能完整解决“Web 录 AMR 给 App 播”。如果要补齐录音,就要自己接 AMR encoder。
自己接 encoder 并不是“给 audio-encode 写个 adapter”那么简单。真正麻烦的地方在底层:
- 麦克风采样率通常不是 8000 Hz,需要重采样。
- AMR-NB 每帧 20 ms,对应 160 个 8kHz 采样。
- 需要处理 PCM 格式转换、帧切分、编码模式、文件头和 blob 输出。
- 还要在真实浏览器里验证权限、延迟、音质、失败恢复和移动端兼容。
因此短期选择是:先用 benz-amr-recorder 把播放和录音闭环接上,再用策略接口把替换口留出来。以后如果要替换成 audio-decode + 自研 / WASM AMR encoder,只需要新增 strategy,而不是改所有业务组件。
为什么不直接让后端转码
后端转码当然是一个很好的长期方向。比如历史 AMR 资源保留原文件,同时后端或媒体服务生成一个 Web 原生可播的 M4A / MP3 / Opus 版本,Web 只拿转码后的地址播放。这样前端包体小,浏览器兼容性也更好。
但它不能替代这次前端改造的全部原因:
- 历史资源已经是 AMR,前端要先让用户能播。
- Web 录音上传给 App,也需要格式约定;如果服务端没有同步转码写回 App 链路,App 端仍然可能不能播。
- 转码需要存储、任务、回源、缓存、失败重试和历史数据处理策略,不是一个纯前端当天能闭环的改动。
所以更实际的做法是分层:
- 前端先通过 AMR 解码 / 编码保证互通。
- 代码层用策略模式隔离底层库。
- 后续如果服务端提供 Web 原生格式,新增一个“转码地址优先”的 strategy。
- 如果未来换更现代的 WASM AMR encoder,也只替换 strategy。
测试要守住哪些边界
这次补测试的重点是守住几个很容易退化的边界,而不是追一个好看的覆盖率数字。
第一类是 source 识别:
.amr必须走 AMR。/voice/xxx.amr必须走 AMR。voicea870...这种无扩展历史 fid 也必须走 AMR。mp3 / wav / webm / m4a这类明确原生格式不能误进 AMR。
第二类是播放 fallback:
- 原生 Audio 失败后,可以 fallback 到 AMR。
- AMR 初始化失败后,可以 fallback 到原生 Audio。
- 所有 strategy 都失败时,必须抛出真实错误,组件不能展示假播放状态。
第三类是录音:
- 录音产物必须是
audio/amr。 - AMR 录音初始化失败时,不允许偷偷 fallback 到普通
MediaRecorder。 - 用户提前松手、初始化竞态、取消录音时,不应该留下一个假的空 blob。
第四类是 UI 状态:
- 点击后先 loading。
- 只有真实
play()成功才进入 playing。 - 结束、失败、销毁都要回到可理解状态。
这些测试的价值不在于证明“某个库现在能跑”,而在于防止后续某次重构为了省事又写回 new Audio(url),或者为了“让录音能成功”把 AMR 互通边界改没了。
总结
- 音频问题不要先猜 UI。先确认 HTTP 状态、CORS、Content-Type、文件头、浏览器
canPlayType()和真实解码错误。 - App 能播不代表 Web 能播。移动端原生媒体栈和浏览器媒体栈不是同一个能力集合。
- AMR 这类历史语音格式要同时看播放和录音。只修播放,可能会把问题留给 App 播 Web 录音。
- 组件不要知道具体编解码库。用统一 controller 和 strategy,把
native Audio、AMR JS 库、WASM decoder、后端转码地址都隔离在底层。 - 播放可以 fallback,录音不能在开始之后 fallback。尤其不能用普通
MediaRecorder假装 AMR 录音兜底。 - UI 的播放态必须跟真实
play()成功对齐。一个没有声音的播放动画,比直接失败更难排查。 benz-amr-recorder适合短期闭环,因为它同时提供 AMR 播放和录音;audio-decode更适合作为未来 AMR 解码替换方向,但 encode 侧还要另找或自研。
这次改动最后留下的核心边界是:业务组件只表达播放和录音意图,编解码能力放进策略层。benz-amr-recorder 只是当前策略里的一个实现细节。等底层库、浏览器支持或后端转码方案变化时,我们换的是策略,不是把每个页面再修一遍。
参考资料
- RFC 4867: RTP Payload Format and File Storage Format for AMR and AMR-WB
- MDN: Web audio codec guide
- MDN: HTMLMediaElement.canPlayType()
- MDN: BaseAudioContext.decodeAudioData()
- Android Developers: MediaRecorder.OutputFormat
- Android Developers: MediaRecorder.AudioEncoder
- benz-amr-recorder README
- audiojs/audio-decode
- audiojs/audio-encode
