浏览器为什么播不了 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。样本里的文件头已经指向 AMR,排查就可以从图片、普通 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 历史数据和播放链路,Web 改录音格式时必须考虑这些消费方。

在这次服务端没有转码、App 仍按 AMR 消费的约束下,Web 也需要 AMR encode 能力:拿到麦克风采样,编码后上传。另一条可行路线是让服务端接收浏览器格式再转成 AMR,但必须同时接好 App 消费链路;不能只换上传扩展名或 Blob MIME type。
实现机制:把播放器做成策略
这类兼容问题最好不要散落在组件里。组件只应该知道「我要播放一段语音」「我要开始录音」「我要拿到录音 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,并展示最小错误提示。
这样 playing 才对应真实播放成功,失败也有明确反馈。音频、视频、支付、上传的 UI 状态都需要跟实际副作用对齐。
两条库路线怎么选
这次调研里主要看了两条路线。
第一条是 benz-amr-recorder。同一个库同时覆盖 AMR 播放和 AMR 录音,README 里写明可以从 URL / Blob 初始化 AMR 播放、录制 AMR,并获取 AMR Blob。对这次互通问题来说,它能最快接上 App 历史语音播放和 Web AMR 录音上传。
项目维护不算活跃,依赖链较旧,包体也不小。我把它作为短期接通互通的方案,长期仍需要考虑替换。
第二条是 audiojs 这一组库。audio-decode 用 JS/WASM 把各种音频格式解成原始采样,支持浏览器和 Node,也把 AMR 解码拆成独立 codec 包。它采用的技术更现代,适合作为未来替换 AMR 解码策略的方向。
但 audio-encode 的支持列表里没有 AMR。audiojs 路线目前能帮我们解决「Web 播 App AMR」,不能完整解决「Web 录 AMR 给 App 播」。如果要补齐录音,就要自己接 AMR encoder。

补齐 AMR 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 拿转码后的地址播放,减少前端解码依赖。这条路线要同时接上录音上传、App 消费和历史资源处理;这次先做前端改造,是因为这些服务端能力尚未接好:
- 历史资源已经是 AMR,前端要先让用户能播。
- Web 录音上传给 App,也需要格式约定;如果服务端没有同步转码写回 App 链路,App 端仍然可能不能播。
- 转码需要存储、任务、回源、缓存、失败重试和历史数据处理策略,不是一个纯前端当天能完成的改动。
所以更实际的做法是分层:
- 前端先通过 AMR 解码 / 编码保证互通。
- 代码层用策略模式隔离底层库。
- 后续如果服务端提供 Web 原生格式,新增一个「转码地址优先」的 strategy。
- 如果未来换更现代的 WASM AMR encoder,也只替换 strategy。
测试要守住哪些边界
测试要覆盖格式识别、策略切换、录音产物和 UI 状态,防止后续替换底层库时破坏 App 与 Web 的互通。
第一类是 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,还要检查文件头和真实解码结果,并在目标 App 播放;只给普通录音 Blob 改 MIME type 不算互通。 - AMR 录音初始化失败时,不允许偷偷 fallback 到普通
MediaRecorder。 - 用户提前松手、初始化竞态、取消录音时,不应该留下一个假的空 blob。
第四类是 UI 状态:
- 点击后先 loading。
- 只有真实
play()成功才进入 playing。 - 结束、失败、销毁都要回到可理解状态。
这些测试要守住播放和录音的互通约定,防止重构后又只用 new Audio(url) 播放历史 AMR,或为了让录音成功而改用 App 无法播放的格式。
最后留下的边界
- 音频问题不要先猜 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适合短期接通 App 和 Web,因为它同时提供 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