浏览器为什么播不了 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() 会报不支持的媒体源。

AMR 问题排查证据链

这里最关键的是文件头。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 文件的基本形态

可以把一个最常见的 AMR-NB 文件想成这样:

  1. 文件头先写 #!AMR\n,告诉解码器这是 AMR-NB。
  2. 后面是一帧一帧的语音数据。
  3. 每帧约 20 ms。
  4. 解码器按帧把它还原成 PCM 采样,再送到播放设备。

PCM 可以理解为“已经展开的原始波形采样”。浏览器、系统声卡和很多音频处理 API 最终都要把压缩格式还原成 PCM 才能播放或处理。MP3、AAC、AMR 这类格式的差异,主要在“怎么压缩、怎么封装、浏览器是否自带解码器”。

App 能播 AMR,是因为 Android / iOS 可以调用系统或应用内的原生媒体能力。Android 这边甚至能直接录 AMR:MediaRecorder.OutputFormat 里有 AMR_NB / AMR_WBMediaRecorder.AudioEncoder 里也有 AMR_NB / AMR_WB。移动端天然离 AMR 更近。

Web 不一样。浏览器原生 Audio<audio> 只支持浏览器内置媒体栈能解的格式。MP3、AAC、Opus、WebM 这类比较常见;AMR 就不能默认假设可播。于是就出现了移动端互通、Web 不出声的断层。

App 原生媒体栈和 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 或库内部的播放能力。

AMR 播放链路

录音链路更麻烦。浏览器的 MediaRecorder 很方便,但它不保证产物是 AMR。Chrome 常见是 WebM/Opus;Safari 又可能是另一路封装。对一个已经有多年 App 历史数据和 App 播放链路的业务来说,Web 不能自己偷偷换协议。

Web 录音要回到 App 能消费的格式

这就要求 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/xxxvoicea870... 这种历史语音 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() 实际失败了,界面上转圈或变成播放中,就是一个假状态。

修完之后,语音播放状态应该是:

  1. 用户点击,进入 loading
  2. 创建播放器 controller。
  3. 调用 controller.play()
  4. 只有 play() 成功 resolve,才进入 playing
  5. 如果失败,回到 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。

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 只拿转码后的地址播放。这样前端包体小,浏览器兼容性也更好。

但它不能替代这次前端改造的全部原因:

  • 历史资源已经是 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 只是当前策略里的一个实现细节。等底层库、浏览器支持或后端转码方案变化时,我们换的是策略,不是把每个页面再修一遍。

参考资料