19、音频静音与音量管理:Master Volume与Stream Volume、音量曲线与映射、静音状态机、音量持久化

各位同学,今天我们来聊聊音频系统里最“接地气”的话题——音量管理。你想想看,用户每天跟手机打交道,按得最多的可能就是音量键了。但音量背后那套机制,说实话,远比大多数人想象的要复杂。

我在做某个平板项目时,就遇到过这么个坑:用户反映调节媒体音量时,前几格几乎听不到声音,后面几格突然炸耳朵。后来一查,是音量曲线映射出了问题。嗯,今天我们就从底层把这些东西彻底捋清楚。

19.1 音量体系:Master Volume 与 Stream Volume

AudioFlinger 里有两套音量体系,一套是全局的,一套是分流的。我习惯把它们比作“总闸”和“分闸”。

  • Master Volume:全局主音量。它作用于所有音频输出。在 AudioFlinger 中,对应 mMasterVolume 变量,范围 0.0 ~ 1.0。
  • Stream Volume:流类型音量。比如媒体、通话、闹钟、系统音效,各自独立调节。每个 AudioTrack 创建时都会绑定一个 stream type。

说白了,最终输出到硬件的声音 = Master Volume × Stream Volume × 应用层音量。这三者相乘,才是你耳朵听到的最终响度。

核心逻辑:AudioFlinger 在混音输出时,会先对每个 Track 应用 Stream Volume,然后对混音后的整体数据应用 Master Volume。这个顺序不能乱,否则会出现奇怪的音量跳变。

19.2 音量曲线与映射:为什么不是线性的?

你可能会问:为什么音量调节不直接用线性比例?比如 50% 音量就是一半响度?

人耳对声音的感知是对数级的。也就是说,音量从 0 调到 10%,你可能觉得变化很大;但从 90% 调到 100%,几乎听不出区别。所以 Android 用了音量曲线来“补偿”这种感知差异。

在 AudioFlinger 中,音量映射的核心函数是 computeVolumeGain()。它会根据 stream type 和当前音量索引,查表计算出最终的增益值。

// 简化后的音量映射逻辑
float AudioFlinger::computeVolumeGain(audio_stream_type_t stream, int index) {
    // 获取该 stream 类型的音量曲线
    const VolumeCurvePoint *curve = getVolumeCurve(stream);
    
    // 根据 index 在曲线上插值
    float gain = interpolate(curve, index);
    
    // 应用 master volume
    gain *= mMasterVolume;
    
    return gain;
}

每个 stream type 都有自己的音量曲线。比如闹钟的曲线就比较“陡”,因为闹钟需要保证在低音量下也能吵醒你。而媒体音量曲线则相对平缓,适合精细调节。

Stream Type 曲线特点 典型应用
STREAM_MUSIC 对数曲线,中段平缓 音乐、视频
STREAM_ALARM 指数曲线,低段陡峭 闹钟、提醒
STREAM_VOICE_CALL 近似线性,范围窄 通话
STREAM_NOTIFICATION 介于对数和指数之间 通知音

避坑指南:我曾经在定制一个车载项目时,客户要求音量调节“更线性”。我直接改了音量曲线表,结果导致某些音量下出现爆音。后来才发现,硬件 DAC 的增益步进也是非线性的。软件曲线要和硬件特性匹配,否则就是白费功夫。

19.3 静音状态机:不是简单的 0 和 1

静音听起来很简单,不就是把音量设为 0 吗?但实际项目中,静音涉及多个状态和过渡逻辑。AudioFlinger 里维护了一个静音状态机,用来处理各种场景。

我画了一张状态机图,方便你理解:

正常播放 音量 > 0 临时静音 音量键静音 永久静音 设置中静音 渐出中 音量逐渐归零 渐入中 音量逐渐恢复 按音量减 设置静音 取消静音 超时恢复 渐出完成 渐入完成

这个状态机里,有几个关键点:

  • 临时静音:比如你按一下音量减到 0,系统进入临时静音。此时再按音量加,会恢复到之前的音量,而不是从 0 开始。
  • 永久静音:在设置里开启“静音模式”,这是持久化的状态。退出静音后,音量恢复到静音前的值。
  • 渐出/渐入:为了避免突然静音或恢复造成的“噗”声,AudioFlinger 会做一段短暂的淡入淡出。这个时间通常在 20~50ms。

注意:静音状态机在低功耗场景下容易出问题。比如耳机插入时,如果系统正在渐出过程中,突然切换音频路由,可能会导致状态机卡死。我建议你在处理静音逻辑时,一定要加超时保护。

19.4 音量持久化:SettingsProvider 与 AudioService

音量值不能每次开机都重置,对吧?Android 的音量持久化走的是 SettingsProvider 这条路。

具体流程是这样的:

  1. 用户调节音量时,AudioService 收到消息。
  2. AudioService 调用 Settings.System.putInt() 写入数据库。
  3. 同时,AudioService 通过 Binder 通知 AudioFlinger 更新音量。
  4. 下次开机时,AudioService 从 SettingsProvider 读取音量值,再设置给 AudioFlinger。
// AudioService 中音量持久化的核心代码
public void setStreamVolume(int streamType, int index, int flags) {
    // 先更新 AudioFlinger 的实时音量
    AudioSystem.setStreamVolume(streamType, index);
    
    // 再持久化到 SettingsProvider
    Settings.System.putInt(mContentResolver,
        getSettingNameForStream(streamType), index);
    
    // 通知其他组件音量已变化
    sendVolumeUpdate(streamType, index);
}

这里有个细节:每个 stream type 在 SettingsProvider 里都有对应的 key。比如媒体音量对应 volume_music,闹钟音量对应 volume_alarm。这些 key 定义在 Settings.System 类中。

我的经验:在做多用户支持时,音量持久化要特别注意。每个用户有自己的音量设置,但 AudioFlinger 是全局的。切换用户时,AudioService 需要重新加载当前用户的音量配置。我曾经在这个问题上踩过坑,切换用户后音量突然变得很大,就是因为没有正确加载音量曲线。

19.5 音量同步:多路输出场景

现在的手机往往有多个音频输出设备:扬声器、耳机、蓝牙、USB-C。每个设备可能有不同的音量范围和曲线。AudioFlinger 需要做音量同步。

比如你正在用蓝牙耳机听歌,音量调到 80%。拔掉蓝牙后,声音切到扬声器。如果扬声器也用 80% 的音量,可能会震耳欲聋。所以 Android 引入了 设备独立音量 的概念。

每个音频设备都有自己的音量索引和曲线。AudioFlinger 在切换设备时,会根据目标设备的曲线重新计算增益值。

// 设备切换时的音量重映射
status_t AudioFlinger::onAudioDeviceChange(audio_devices_t newDevice) {
    // 获取新设备的音量曲线
    const VolumeCurvePoint *newCurve = getDeviceVolumeCurve(newDevice);
    
    // 根据当前音量索引,在新曲线上找到对应的增益
    float newGain = mapVolumeToCurve(mCurrentVolumeIndex, newCurve);
    
    // 应用新增益
    setMasterVolume(newGain);
    
    return NO_ERROR;
}

嗯,这里要注意:不同设备的音量曲线差异可能很大。比如扬声器的曲线通常比较“平”,因为扬声器的动态范围有限。而耳机的曲线则更“陡”,因为耳机能表现更细腻的音量变化。

19.6 总结与思考

音量管理看似简单,实则涉及状态机、曲线映射、持久化、设备同步等多个维度。我建议你在阅读 AudioFlinger 源码时,重点关注以下几个文件:

  • AudioFlinger.h:查看音量相关的成员变量
  • AudioPolicyManager.cpp:音量策略和曲线定义
  • AudioService.java:音量持久化和用户交互

最后留个思考题:如果让你设计一个“自适应音量”功能,根据环境噪声自动调节媒体音量,你会怎么实现?提示:可以结合音量曲线和状态机来做。

好了,这一章就到这里。音量管理这块内容比较多,建议你动手写个小 demo,模拟一下音量曲线的插值计算,感受会更深刻。


公众号:蓝海资料掘金营,微信deep3321