浏览器为什么播不了 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。样本里的文件头已经指向 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 文件的基本形态

可以把一个最常见的 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_WB,MediaRecorder.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 历史数据和播放链路,Web 改录音格式时必须考虑这些消费方。

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

在这次服务端没有转码、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() 实际失败了,界面上转圈或变成播放中,就是一个假状态。

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

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

补齐 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 是当前策略的实现之一;底层库、浏览器支持或后端转码方案变化时,可以替换策略,避免逐个页面重新适配。

参考资料