ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高通平台音频控件封装:从tinymix到C++可复用模块

高通平台音频控件封装:从tinymix到C++可复用模块 如果你在高通平台上调过音频大概率会有一段被 tinymix 支配的回忆。拿手机或者开发板来说耳机通路要开一堆 “RX3 Mixer IN1” 之类的控件听筒切换要关这个开那个稍不留神 pk 日志里全是 “Failed to set ctl” 的报错。这个事做一次两次可以一旦要写成可持续交付的代码就必须把 “音频控件封装” 这件事系统化地做起来。这篇文章我不会讲太多虚的架构图直接围绕 Qualcomm 平台的 ALSA/ASoC 音频架构说清楚底层控件到底是个什么东西为什么不能靠命令行硬撸以及怎么用 C 把它封装成一套可复用、可维护、可测试的音频控制模块。内容偏向手机、平板、车机和物联网设备上做音频 HAL、AudioPolicy 或上层音频 SDK 的人群也适合刚接触高通音频、被控件名搞得一头雾水的初学者。1. 先从高通音频架构说起到底什么是“音频控件”1.1 一条音频数据要走的完整链路在高通平台比如骁龙系列的 SM8250、SM8450 或者更早的 MSM8953上音频从应用层到最终发声大致要经过这么几层Android 的 AudioFlinger 把音频流交给 Audio HALHAL 再通过 tinyalsa 或者直接操作 ALSA 设备节点把数据送到 kernel 的 ASoC 框架ASoC 最终把数据送给音频编解码芯片Codec比如高通常用的 WCD9340、WCD9380或者更为集成的音频 DSP 通路。这条链路上每一级都有可能被打断。不是说你往声卡节点写入了 PCM 数据声音就能出来。在数据走通之前还必须要打开相对应的“通路”——也就是 mixer 通路。比如播放音乐时你要把 MultiMedia1 的音频流路由到某个 RX 端口再把 RX 端口连接到的外部功放或耳机通路打开。这些“打开”动作落到内核里就是在操作一个个 kcontrol。1.2 控件就是这条链路里的“开关和旋钮”kcontrolkernel control是 Linux ALSA 里抽象出来的一个概念你可以把它理解成一个具有读写属性的寄存器。它在用户空间通过 tinymix 命令暴露出来对应的就是你在终端里看到的那些控件名。用生活里的事情来类比音频链路就像一套家庭影院的接线。功放、音箱、播放器、低音炮各自有开关、音量旋钮和输入源选择。你想看电影就得把播放器接到功放的 HDMI1 口把功放的输出切到主音箱再把音量旋到合适位置。音频控件就是这些开关和旋钮tinymix 命令就是你手上的遥控器。高通平台的控件命名有自己的一套规律常见的是 “XXX Mixer XXX”、“XXX Digital Volume”、“XXX Gain” 等。举个例子在老的 msm8996、骁龙660/670 时代播放音乐通常会涉及 “RX3 Mixer IN1” 这个控件它决定 RX3 通路是否把 MultiMedia1 的音频数据放进去。又比如耳机插拔检测相关的 “Headphone Jack” 控件或者 Codec 内部功放开关 “SPK PA Gain”。1.3 高通平台控件命名规律与常见控件很多人第一次接触高通音频会被超级长的控件名吓到但实际上控件命名是有规律的。常见前缀代表了所在的模块MI2S/PRI_MI2S/SEC_MI2SI2S 总线接口用于连接外部 Codec 或蓝牙音频。RX/TX接收和发送通路。RX 是播放下行TX 是录音上行。MultiMedia1~MultiMediaX和音频 DSP 里多个 session 对应一个多媒体会话。WSA/WCD高通集成音频编解码器相关例如 WCD9340 内部的各种通路。熟悉这套命名之后你在封装控件时就不会两眼一抹黑。你需要做的不是把几千个控件全部封装一遍而是提炼出业务真正需要的那几十个再封装成稳定易用的接口。2. 封装前必须想清楚的原则与设计2.1 天天手敲 tinymix 为什么不可持续我在刚接触高通音频时也干过这种事产品调试阶段每次调通路就在 adb shell 里敲一串 tinymix 命令。比如播放音乐时敲tinymix RX3 Mixer IN1 1 tinymix RX3 Digital Volume 84 tinymix RX3 PA 1这样做的缺点特别明显第一命令串长了之后根本记不住而且写进脚本容易出错第二不同平台控件名不一样换一个项目又要重新敲一遍第三这样只能做临时验证做不了产品功能。比如你的产品要做语音助手、要支持按键切换音效或者要在车机上根据倒车雷达声音自动调低媒体音量靠 adb 命令是不可能实现的。这个痛点就是封装的意义。封装的本质是把底层千变万化的细节“关起来”对外只暴露稳定的 API打开通路、关闭通路、设置音量、选择音源。上层业务不用关心当前是哪颗 Codec、控件叫什么名字。2.2 封装设计的四个层次我通常把音频控件封装分成四层每层职责单一方便测试和移植第一层是设备抽象层对应实现物理控件的读写。这一层只需要知道声卡编号、控件名和值做最基础的操作。第二层是通路控制层维护一个路由表中的所有控件状态负责“从 A 通路切到 B 通路”这种批量动作。第三层是业务策略层上层只管说“我要播放音乐”“我要录音”“我要打电话”这一层自动选择通路屏蔽路由细节。第四层是应用接口层提供给 Java/Kotlin 或其它模块调用通常通过 JNI 或者 binder。这四层不一定要同时存在。如果你只是做一个工具库第一层和第二层就够用了如果你要封装成 Audio HAL 的一部分那策略层和应用接口层很关键。2.3 和高通自带的 audio_route 库对比我们还需要什么稍微有点经验的人会知道高通在 Audio HAL 里其实自带了一个 audio_route 库它也做控件封装。它通过解析 audio_route.xml 里的通路配置把一组控件赋值操作映射成一个 route上层 Audio HAL 调用 audio_route_apply_path 就可以切换通路。那为什么还要自己封装我碰到的主要问题有两个。一是高通的 audio_route 库和它的 HAL 绑定很深想单独拿出来用于自己的应用层工具非常麻烦。二是它的配置是 XML 驱动灵活性高但排查问题不够直观。你在 XML 里写错一个控件名运行时不一定会立刻报错而是在切通路后无声你还得去抓 tinymix 对比。自己封装时我可以设计一个更贴近当前项目的错误反馈机制比如控件设置失败后直接返回错误码并打印详细日志定位问题快很多。但同时我不建议完全抛弃 audio_route 的思路。控件批量设置这一招值得学过来也就是用一张表来描述一条通路避免通路切换代码散落在各个业务模块里。3. 实操用 C 封装一套可复用的音频控件控制库3.1 基础tinyalsa 控件读写封装我平时用的最多的是 tinyalsa 库它在 AOSP 里自带相比直接操作 /proc/asound 或者通过 ioctl 要方便很多。封装控件读写我建议先写一个最底层的类类名可以叫 AudioMixerControl它的核心能力就是两个方法读控件值和写控件值。下面是一份简化的实现思路#include tinyalsa/asoundlib.h #include string #include mutex class AudioMixerControl { public: AudioMixerControl(int card) : mCard(card), mMixer(nullptr) { mMixer mixer_open(mCard); if (!mMixer) { // 这里要记录错误mMixer 为空时后续操作全部失败 } } ~AudioMixerControl() { if (mMixer) { mixer_close(mMixer); } } int setValue(const std::string ctlName, int value) { std::lock_guardstd::mutex lock(mLock); if (!mMixer) return -ENODEV; struct mixer_ctl* ctl mixer_get_ctl_by_name(mMixer, ctlName.c_str()); if (!ctl) { // 打日志控件不存在 return -ENOENT; } if (mixer_ctl_set_value(ctl, 0, value) 0) { // 打日志设置失败 return -EINVAL; } return 0; } int getValue(const std::string ctlName, int value) { std::lock_guardstd::mutex lock(mLock); if (!mMixer) return -ENODEV; struct mixer_ctl* ctl mixer_get_ctl_by_name(mMixer, ctlName.c_str()); if (!ctl) return -ENOENT; value mixer_ctl_get_value(ctl, 0); return 0; } private: int mCard; struct mixer* mMixer; std::mutex mLock; };这里有几个细节要重点说明。第一个是 mixer_open 的时机建议在构造函数里打开、析构函数里关闭不要每次读写都开关一次。因为 mixer_open 内部会去访问 ALSA 设备节点频繁开关容易引入不必要的开销还可能在某些休眠场景下触发异常。第二个是加锁控件读写看起来简单但如果是多线程环境比如一个线程在切通路另一个线程在调音量不加锁会出现操作交叉最终导致寄存器状态错乱。第三返回值尽量用标准 errno比如 -ENODEV、-ENOENT、-EINVAL调用方可以快速定位问题。当然tinyalsa 的 mixer_ctl_set_value 并不只支持 int还支持 enum、bool 等类型。高通控件里有很多是枚举值比如某个 mixer 控件的取值不是 0/1而是 OFF/ON/HPH/SPK这时候用 int 设置反而容易出错。稳妥做法是先读一下控件类型再根据类型选择赋值方式。完整的处理我会在下面展开。3.2 进阶本地路由与通路切换读写单控件只是基础日常用得更多的是“切换通路”。打个比方听音乐时音频走耳机来电时音频要切到听筒来消息提醒时可能要短暂从媒体通路切到提示音通路。这些动作的底层对应着一组控件的状态变化。我会把通路定义成一张表每个表项是一个“控件名 值”的键值对。举个例子耳机通路可能是struct AudioControlItem { std::string ctl; int value; }; struct AudioRoute { std::string name; std::vectorAudioControlItem items; };然后定义几条通路AudioRoute kRouteSpeaker { speaker, { {RX3 Mixer IN1, 1}, {RX3 Digital Volume, 84}, {SPK PA Gain, 6}, // 不同平台控件名会有差异 {SPK PA, 1} } }; AudioRoute kRouteHeadphone { headphone, { {RX1 Mixer IN1, 1}, {RX1 Digital Volume, 80}, {HPHL Volume, 12}, {HPHR Volume, 12}, {HPH PA, 1} } };通路切换时只需要传入 name 就能完成一套组合动作。这里有一点我的个人经验刚开始从命令行搬命令到代码里的时候很多人喜欢直接在函数里一条条写 setValue这样虽然简单但后续维护特别痛苦。换成表驱动之后加一条通路只是一个数组项不需要变动任何逻辑代码特别是到了工厂调试阶段调试人员甚至可以在配置里直接增删项。切换时还要注意操作顺序。一般来说打开新通路之前先不要急着关旧通路否则会出现瞬间无声甚至爆音。我一般分两步第一步把新通路的所有控件全部设置到位第二步再关掉旧通路。如果你把步骤反过来先关了耳机通路再打开喇叭通路切换瞬间会有明显的一段时间听不到声音体验很差。3.3 完善音量渐变与 pop 音规避很多新人在设置音量时直接调 Digital Volume 控件一下从 0 跳到 80听起来虽然没有大毛病但在手机通话音量或者车机媒体音量场景里会听到明显的爆破声或者“啪”的一声。这个问题的根源是音量突变导致 DAC 输出电压瞬间跳变。为了规避这种噪声我会在封装层加一个音量渐变功能。思路很简单不直接把目标音量写进去而是从当前音量开始每隔一小段时间步进一个固定值直到达到目标音量。步进间隔一般取 20ms每次步进 2~3 格整体听感会平滑很多。int AudioMixerControl::setVolumeWithRamp(const std::string ctlName, int target, int step, int intervalMs) { int current 0; int ret getValue(ctlName, current); if (ret ! 0) return ret; while (current ! target) { if (target current) { current step; if (current target) current target; } else { current - step; if (current target) current target; } ret setValue(ctlName, current); if (ret ! 0) return ret; usleep(intervalMs * 1000); } return 0; }这个函数实现很朴素但要注意不能在音频线程里直接 usleep 阻塞。如果是在 AudioFlinger 严格线程模型下调用建议把渐变过程放到独立线程或者用定时器分多次回调。另外pop 音不只在音量渐变时出现。关闭 PA功放时如果时机不对也可能听到“噗”的一声。经验是先 mute 数字音量再关 PA开功放时反过来先开 PA再取消 mute。这些顺序都需要写进路由表里。在封装层我会在 AudioRoute 结构里加一个 stage 字段把控件分成“先执行”“后执行”两个阶段这样能从根本上规避顺序问题。3.4 集成到 Android Audio HAL 的思路如果你的目标不只是做一个工具库而是要接入 Android 的音频体系那封装的形态就变成 Audio HAL 里的一个内部模块。需要面对两个问题一是如何拿到当前路由状态二是如何对接 AudioPolicy 的路由请求。我见过的方案大致分两种。一种是直接替换高通的 audio HAL 代码在自己的 HAL 里调用上述封装库资源管理、通路选择逻辑全部自研。这种方案灵活但工作量大要处理 AudioPolicy 的各种怪异路由请求。另一种是在高通 Audio HAL 的外围做拦截比如通过修改 audio policy 配置把路由变化事件上报给自己的服务再由服务调用底层封装接口。这种更适合在既有平台上做增量开发。核心思路是你可以把 AudioMixerControl 做得足够独立上层无论是 AudioPolicy、AudioFlinger 还是应用层 JNI都通过一套统一的 API 调用比如 startOutput、setOutputVolume、setParameters。这样即使平台换了控件名变了你的上层业务代码也不用动只需要更新路由表和驱动配置。4. 常见问题与排查技巧实录4.1 控件找不到或者读写失败这个问题我遇到得太多了几乎每个新平台都有。最常见原因有三个一是控件名拼写不对尤其是中间的空格大小写不对二是当前 power 状态不对有些控件在 Codec 休眠时不会被枚举三是控件所属声卡不对比如高通平台通常有 card0 和 card1你打开了 card0 去读 card1 上的控件当然失败。排查手段很朴素先在终端执行tinymix -D 0和tinymix -D 1看控件在哪个声卡。然后把需要的控件名列出来逐个确认。我写代码时通常会加一个 debug 接口专门导出所有控件名方便对比。4.2 切完通路没声音代码里控件设置全部返回成功但就是没声音这是第二大类问题。我提供一个相对高效的排查顺序先看 PCM 有没有出数据也就是 tinyplay 播放时/proc/asound/card0/pcm0p_sub0的 status 是否 Running。再看 mixer 通路状态逐项和一份“已知可以出声音”的参考配置对比。最后看功放/PA 有没有使能很多平台的 PA 使能需要 GPIO 或者外部控制不会自动跟随 mixer 控件。另外高通平台有个特别坑的地方通路切换后要等一小段时间让 DSP 或 Codec 完成切换马上播放会出现首段音频丢失。我一般在通路切换后加一个 50ms 的延时不影响实际体验但能减少很多偶发无声。4.3 爆音、pop 音、电流声爆音有三个常见来源音量突变、PA 开关时序不对、音频时钟不稳定。音量突变靠渐变就能解决PA 开关时序靠路由表阶段控制解决电流声则比较复杂。有一次我调试录音底噪偏大发现问题和控件封装本身无关而是某个 TX 通路没有关闭导致麦克风信号和 Codec 内部信号串扰。这种问题靠单步调控件很难发现得用分层排查法先关掉所有通路再测噪声然后一条条打开确认是哪一条引入的噪声。4.4 并发与生命周期问题并发问题高发于两个地方多线程同时调 setValue以及通路切换和音量调节交叉。不上锁或者锁粒度过大都会出问题。我个人的习惯是AudioMixerControl 内部用一个 mutex 保证单控件操作原子性路由切换用另一个锁粒度是整条路由。这样做能避免切路由的过程中音量调节插进来让控件值处于半套旧半套新的状态。还有一点在系统休眠时不要调用控件接口因为此时 ALSA 设备可能已经 suspend写控件大概率会失败或卡住。4.5 排查工具与速查表我最后整理一个我平时排查问题的速查表你可以存下来现象可能原因快速排查动作控件找不到声卡不对/名称错了用 tinymix -D N 确认声卡和控件名控件设置成功但无声通路没完全打开/PA 没使能tinyplay 播放并检查 pcm status切换通路瞬间爆音音量突变/PA 时序不对加音量渐变路由分阶段设置录音噪音大TX 通路串扰/时钟异常关闭所有通路逐步排查偶发无声切换后未等待收敛路由切换后加 50ms 延时休眠后写失败设备 suspend唤醒后再操作避免休眠期间调用这个表是我每次换平台调试都会拉一遍的清单。实际操作比写代码更磨人但有了这套封装和排查思路至少不用全靠瞎猜。我在实际项目中还有一个小习惯封装层里一定要留一个“手动模式”可以通过 socket 或者属性开关把底层控件直接暴露给调试端。这样出现问题时我可以先用命令行手动复现找到是驱动问题还是封装逻辑问题再决定在哪一层修。毕竟封装越厚问题越难定位。留一条直通底层的调试通道能省掉一半的扯皮时间。
返回列表