今天突发奇想:MacBook 的屏幕开合角度,能不能直接拿来当系统音量的物理滑块用?
顺着这个脑洞,我用 C++ 写了个命令行小工具——屏幕掰得越开,音量越大。原理不复杂:通过 IOKit 读取屏幕铰链的 HID 传感器角度,再用 Core Audio 去改当前输出设备的音量。在我的电脑上,屏幕最大能掰到 133°,所以我直接把 0°~133° 线性映射到了 0%~100% 的音量。
实测效果还挺好玩的:把屏幕掰到 99° 时,程序算出目标音量约为 74.4%,写入后再从系统回读,返回 74.44%,整条链路就这么跑通了。
下面聊聊实现过程中摸索出的几个系统接口细节,以及写代码时做的一些工程取舍。
怎么用代码调 macOS 的音量?
平时按键盘调音量感觉是全局的,但在 Core Audio 里,“调系统音量”实际上是去改当前默认输出设备的主音量属性。这里有个坑:内置扬声器、3.5mm 耳机、外接显示器音响,它们支持的底层属性可能不一样,不能想当然地认为是个音频设备就能直接写主音量。
Core Audio 的接口设计是一套典型的“对象 + 属性(Object-Property)”模型。要改音量,得先做两次查询:
系统音频对象 (System Object)
└─ 1. 查当前默认输出设备 → 拿到 AudioDeviceID
└─ 2. 查/改主输出音量 → Float32 [0.0 ~ 1.0]
这两步调的都是同一个 C 函数 AudioObjectGetPropertyData,只是传进去的对象 ID 和属性地址(Property Address)不同:
| 查什么 | 目标对象 | Selector | Scope / Element |
|---|---|---|---|
| 默认输出设备 | kAudioObjectSystemObject | kAudioHardwarePropertyDefaultOutputDevice | Global / Main |
| 主输出音量 | 第一步拿到的设备 ID | kAudioDevicePropertyVolumeScalar | Output / Main |
音量的值域是 0.0 到 1.0 的 Float32。这套 C 接口用起来很传统:函数返回值是代表状态的 OSStatus,真正的数据要靠传指针写回缓冲区;写音量时则换成 AudioObjectSetPropertyData。
// address 已提前配好,指向主输出音量属性
Float32 volume = 0.0f;
UInt32 size = sizeof(volume);
OSStatus status = AudioObjectGetPropertyData(
device, &address, 0, nullptr, &size, &volume);
// 算好 targetVolume 后写回系统
status = AudioObjectSetPropertyData(
device, &address, 0, nullptr,
sizeof(targetVolume), &targetVolume);
注意个细节:Get 传的是 &size(因为系统要告诉你实际读了多少字节),而 Set 直接传 sizeof(targetVolume)。调 AudioObjectGetPropertyData 这类底层接口,最核心的就是保证属性、缓冲区类型和大小严格对齐。
为了不让业务代码里到处都是 C 风格的指针和状态码检查,我把它们包成了三个简单的函数:
AudioDeviceID getDefaultOutputDevice();
Float32 getVolume(AudioDeviceID device);
void setVolume(AudioDeviceID device, Float32 volume);
底层校验都收拢在这三个函数里:先查属性存不存在、能不能写,再检查每次调用的 OSStatus。传进来的音量参数如果越界就抛 std::invalid_argument,系统调用挂了就抛 std::runtime_error,统一交由外层捕获。
在做参数校验时,除了判断 volume < 0.0f || volume > 1.0f,还得顺手加上 std::isfinite。因为浮点数里的 NaN 跟任何数比大小都是 false,只靠大小判断会直接漏过去。
另外,翻 Apple 的 AudioObjectSetPropertyData 文档 时发现个细节:Set 函数返回成功,并不代表硬件已经同步改完了音量,有些设备的属性更新是异步的。所以我在日志里打印的是“已提交目标音量”,而不是立刻回读去强行校验,免得遇到异步设备时出现假阳性报错。
读取屏幕开合角度
读取屏幕角度没有自己从头写驱动交互,而是直接用了开源库 ufoym/mac-angle。它已经把 IOKit HID 设备查找、打开、读取和释放都封装好了,调一个 LidAngleSensor::readAngle() 就能拿到角度。
在我的机器上,它通过以下特征匹配到了铰链传感器:
| 匹配项 | 值 |
|---|---|
| Vendor ID | 0x05AC (Apple) |
| Product ID | 0x8104 |
| Usage Page | 0x0020 (Sensor) |
| Usage | 0x008A |
去翻了下它的源码实现,底层其实是调 IOHIDDeviceGetReport 读取 ID 为 1 的 Feature Report,然后把报告里的两个字节拼成角度值。因为这些匹配条件和字节解析规则属于对苹果私有协议的逆向,换个代际的 MacBook 未必通用。
顺便提个看源码时发现的槽点:这个库的 README 里写着传感器分辨率是 0.01°,但看代码就会发现,它把两个字节拼出整数后,直接强转成 double 就返回了。所以就算程序打出了 99.00°,它的实际精度也依然是 1°,并没有小数位。
工程组织上,我直接用 CMake 的 add_subdirectory 把这个库引了进来,主程序直接 link lid_angle 即可,IOKit 和 Core Foundation 的依赖都已经由库自身的 CMake 处理好了。
角度怎么映射到音量?
库文档里写的数据范围上限是 360°,但 MacBook 显然不是翻转本,不能拿协议上限当控制上限。
我把合盖状态定为 0°,然后把自己的电脑屏幕掰到底,实测最大角度是 133°。目前这两个值直接写死在编译期配置里,还没做首次运行的自动校准。
映射逻辑被单独抽成了一个纯函数,输入当前角度和上下限,返回 [0, 1] 的音量百分比:
const double boundedAngle = std::clamp(angle, closedAngle, maximumAngle);
return static_cast<float>(
(boundedAngle - closedAngle) / (maximumAngle - closedAngle));
计算前会先过滤掉非有限值(NaN / Inf)和不合理的上下限配置。先把角度 clamp 住再做减法,也能防止极端异常值导致浮点溢出。按这个比例,屏幕打开到一半(66.5°)正好是 50% 音量,掰到底(133°)就是 100%。
当然,声音的物理标量(Scalar)和人耳感受到的响度并不是线性的。目前用一次函数映射只是最简单的做法,好在这块逻辑是独立的,以后想改成对数曲线改善调节手感,改这一个函数就行。
主循环里的状态细节
主程序目前是个简单的同步轮询,每 100ms 读一次传感器。算上 IOKit 和 Core Audio 的调用开销,实际频率略低于 10Hz,不过拿来验证交互已经足够跟手了。
为了避免每 100ms 都往系统狂刷一次音量设置,循环里记了两个状态:
std::optional<float> lastTargetVolume;
AudioDeviceID lastDevice = kAudioObjectUnknown;
只有在首次运行、算出的目标音量变了,或者默认输出设备发生了切换时,才真正去调 Core Audio 写音量:
if (!lastTargetVolume ||
targetVolume != *lastTargetVolume ||
device != lastDevice)
{
setVolume(device, targetVolume);
lastTargetVolume = targetVolume;
lastDevice = device;
}
这里有几个容易踩坑的细节:
lastTargetVolume必须在setVolume成功后才更新。如果某次系统调用抽风失败了,下一轮循环还得重试,不能当作已经改过了。- 每次循环都要重新查一次默认输出设备。如果为了省事在启动时把
device缓存死,中途用户插上耳机或连上 AirPods,只要屏幕没动,新设备就不会应用当前角度的音量;就算屏幕动了,音量也会改到旧设备上去。 - 不和用户的手动操作死磕。如果屏幕停在某个角度,用户自己按键盘改了音量,只要屏幕角度没变、也没换耳机,程序就不会强行把音量覆盖回来——直到下次再掰动屏幕,才重新接管控制权。
为了保证优雅退出,传感器对象是在循环外创建的。收到 Ctrl+C 或 SIGTERM 时,信号处理函数只负责把一个 volatile std::sig_atomic_t 标志位设为退出状态,让主循环自己走完当前轮次并正常跳出,确保局部对象走完析构、干净地释放 HID 设备句柄。
怎么在不吵到自己的情况下跑测试?
如果每次跑单元测试都要真去改系统音量、或者非得掰一下屏幕,这测试就没法自动化了。所以我把硬件副作用全挡在了测试边界之外,拆了三组测试:
- 映射算法测试:纯数学逻辑测试,覆盖边界值、中点、越界截断、单调性,以及
NaN和无穷大等脏数据,完全不依赖 macOS 硬件环境。 - 控制循环测试:直接编译真实的
main.cpp,但在链接阶段替换成 Mock 的传感器和音频接口。测试脚本会喂进去一串包含“重复角度”和“中途切换音频设备”的数据流,专门验证程序有没有跳过重复写入、换设备时有没有补发音量设置,以及发信号能不能正常退出。 - Dry-run 模式测试:验证加上
--dry-run参数后,程序绝对不会去碰 Core Audio 的查询和写入接口。
跑完单元测试,真机验证也分成了两步:
# 1. 只读真机传感器并打印算出的音量,不改系统设置
./build/MacLidVolumeSlider --dry-run --once
# 2. 按当前屏幕角度,真刀真枪改一次系统音量
./build/MacLidVolumeSlider --once
开头提到的“99° 映射到 74.44%”,就是用命令 2 写入后,再单独查一次系统音量确认的结果。
目前的局限与已知边界
虽然跑起来挺有意思,但作为一个玩具项目,它还有很多没处理的现实边界:
- 合盖睡眠问题:理论上 0° 对应静音(0%),但你把屏幕合上的时候,系统往往在程序采样到 0° 之前就已经休眠了。所以它并不能保证合盖瞬间一定能把音量归零。
- 外设兼容性:目前只读写了
kAudioDevicePropertyVolumeScalar主音量属性。遇到一些不支持主音量调节(比如只支持分声道调节,或者直接锁死音量的外接 DAC/显示器)的设备,程序会直接报错退出,还没做降级回退,也不会自动帮你解除静音(Unmute)。 - 缺少常驻与重连:硬件断开或读取报错后没有自动重连机制;退出程序时也不会恢复启动前的原音量。
把这些限制列出来,其实也是在理清这类小工具的交互边界——毕竟牵涉到硬件状态,换耳机、合盖、手动调音量时的边缘情况,往往比核心的那几行映射代码更让人头疼。
编译与运行
如果你手头有支持角度传感器的 MacBook,并且装了支持 C++17 的工具链和 CMake(3.31+),可以直接拉代码试试(mac-angle 已经放在 third_party/mac-angle 下了):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failure
# 持续预览当前角度和目标音量(不实际修改)
./build/MacLidVolumeSlider --dry-run
# 实际接管音量控制,按 Ctrl+C 退出
./build/MacLidVolumeSlider
整个项目的代码结构目前非常扁平:audio_volume 管 Core Audio 交互,volume_mapping 管数学计算,main 负责状态轮询与胶水逻辑。核心架子搭好后,后面如果有空,也许会再给它加上开机自启、自动校准或者塞进 macOS 顶部的菜单栏里。
发表回复