跳到正文

浏览器为什么播不了 App 语音:一次 AMR 互通和策略模式改造

这次问题一开始看起来很像一个普通的资源加载失败:用户资料页有语音介绍,Web 上点播放,UI 转起来了,但没有声音,控制台里还有资源错误。更麻烦的是,QA 补充说同一条语音在 Android 和 iOS 之间能互通,只有 Web 不行;Web 录出来的语音,App 端也播不了。

单看 UI,还无法区分接口未返回、OSS 链接失效、CORS 拦截和自动播放策略限制。这次排查最终落在格式上:文件是 AMR,项目里的 App 播放器能播,测试的 Chrome 原生 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 这类格式的差异,主要在「怎么压缩、怎么封装、浏览器是否自带解码器」。

本文能确认的是项目中 Android / iOS 两端的 AMR 链路可以互通,具体能力来自系统还是 App 内的编解码实现,需要分别核对。Android 的 MediaRecorder.OutputFormat 提供 AMR_NB / AMR_WB,MediaRecorder.AudioEncoder 也有对应编码器;不能据此推断 iOS 系统同样原生提供 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 也不能播」的原因之一:两端不是同一种编解码约定。

Web 既要解码 App 上传的 AMR,也要生成 App 能播放的录音文件。

播放链路里,普通 MP3 / M4A / WAV 仍然可以交给浏览器原生 Audio。只有遇到 AMR,才需要走额外的解码器:下载或初始化资源,把 AMR 帧解成 PCM,再交给 Web Audio 或库内部的播放能力。

录音链路更麻烦。浏览器的 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。URL 只用于决定尝试顺序,不能代替文件头和解码结果的确认。play() 的错误契约还区分 NotSupportedError 与播放权限相关的 NotAllowedError;权限、网络或 CORS 失败应该返回对应反馈,不能一律当成换解码器的理由。

格式不支持时才换候选,播放态等待实际启动

播放可以 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,并展示最小错误提示。

controller 的 play() 需要把 Promise 定义为「底层已确认开始播放」,不能只创建一个节点就立即 resolve。原生 Audio 可按其 play() 结果处理,自定义库则要对齐真实启动事件;后续结束、解码错误和销毁仍需更新 UI。这样 loading、playing 和失败反馈才有对应的实际状态。

两条库路线怎么选

这次调研里主要看了两条路线。

第一条是 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 的互通约定不能因替换底层库而改变。完整内容见播放器与解码策略的回归弹窗。

总结

这次语音无声的样本已经成功返回,文件头和解码探测把问题定位到 AMR 格式支持:项目里的 App 能播,测试的 Chrome 原生 Audio 无法解码。互通还包括反向录音链路;在服务端尚未接好转码、App 仍按 AMR 消费的条件下,Web 需要同时具备 AMR 解码和编码能力,修改扩展名或 Blob MIME type 无法完成适配。

短期用 benz-amr-recorder 接通播放与录音,业务组件只表达意图,策略层隔离编解码实现。播放遇到格式不支持时可以换候选策略,权限、网络和 CORS 错误则要单独反馈;录音开始后不能偷偷换成 App 无法消费的格式。UI 也要等待底层确认开始播放,再进入 playing。

benz-amr-recorder 能同时接上播放和录音,但维护不算活跃、依赖链较旧,包体也不小,因此这里只把它作为短期方案。audio-decode 路线仍需补齐 AMR encoder,后端转码则要同时处理历史资源、录音上传和 App 消费。替换策略时,仍需验证真实文件、目标 App 播放和 UI 状态,不能只检查路由顺序或 MIME 声明。

参考资料

播放器与解码策略的回归

测试要守住哪些边界

测试要覆盖格式识别、策略切换、录音产物和 UI 状态,防止后续替换底层库时破坏 App 与 Web 的互通。

第一类是 source 对候选策略顺序的影响:

  • .amr 优先尝试 AMR 策略。
  • /voice/xxx.amr 优先尝试 AMR 策略。
  • voicea870... 这种无扩展历史 fid 也优先尝试 AMR 策略。
  • mp3 / wav / webm / m4a 这类扩展名优先尝试原生策略。

这些断言检查路由顺序,不替代后续文件头检查和实际解码。

第二类是播放 fallback:

  • 原生 Audio 明确不支持格式时,可以按候选顺序尝试 AMR。
  • AMR 解码不适用时,可以按候选顺序尝试原生 Audio;权限、网络和 CORS 错误单独反馈。
  • 所有 strategy 都失败时,必须抛出真实错误,组件不能展示假播放状态。

第三类是录音:

  • 录音产物不仅要声明 audio/amr,还要检查文件头和真实解码结果,并在目标 App 播放;只给普通录音 Blob 改 MIME type 不算互通。
  • AMR 录音初始化失败时,不允许偷偷 fallback 到普通 MediaRecorder。
  • 用户提前松手、初始化竞态、取消录音时,不应该留下一个假的空 blob。

第四类是 UI 状态:

  • 点击后先 loading。
  • 只有真实 play() 成功才进入 playing。
  • 结束、失败、销毁都要回到可理解状态。

这些测试要守住播放和录音的互通约定,防止重构后又只用 new Audio(url) 播放历史 AMR,或为了让录音成功而改用 App 无法播放的格式。