Featured image of post AMExclusive,如何给予 Windows 端 Apple Music 独占输出的能力

AMExclusive,如何给予 Windows 端 Apple Music 独占输出的能力

背景,现状

Apple Music,苹果公司互联网业务的一颗明珠。其成功固然离不开与健全的苹果硬件生态的融合,但同时,苹果直接直接与唱片公司对接拿到母带,并以此优势达到流媒体音频质量第一梯队,因此在音乐发烧友群体,Apple Music 的声誉也非常高。

然而,Apple Music 的音频质量优势在音源阶段后戛然而止。在The Computer Audiophile — Apple Music’s Lossless and Hi-Res Mess,The Computer Audiophile — Apple Music’s Lossless and Hi-Res Mess Page 7,Stephen Dawson — Bit-perfect? I tested AirPlay, Google Cast and DLNA的研究中,即使是被大多数奉为圭臬的 iPhone,iPad,Mac,也无法做到全时 bit-perfect 输出。在 Windows 端,这一情况则更加糟糕。接下来,我们就要尝试改善这一情况。

拆解,开盒

第一步:在不改变播放结果的前提下,观察不同位置的格式和数据。

(本轮分析主要基于 Windows 版 Apple Music 文件版本 1.6.4.90,包及框架版本 1.1540.23042.0, x86-64)

先确定进程边界。Apple Music 的界面进程与承担媒体工作的AMPLibraryAgent.exe是分开的,因此我们需要观察 AMP 中与音频有关的模块、导出函数、对象创建和生命周期。以下是重点记录信息:

  • AudioConverter的输入和输出格式:编码类型、采样率、声道数、每帧字节数、整数或浮点。
  • AudioUnit的输入输出格式,初始化、启动、渲染调用。
  • WASAPI 的IAudioClient::Initialize参数、共享或独占,以及IAudioRenderClient提交的缓冲。
  • 对象标识、调用顺序、线程、时间戳、实际产生的帧数和曲目切换之生命周期。

格式信息通常由AudioStreamBasicDescription描述,以下简称ASBD。它可以告诉我们一个对象输入、输出的数据是什么样子,但不会自动告诉我们这是不是当前正在听的歌曲。播放器可能同时预取出下一首、保留旧对象、创建多个转换器,因此还要把转换器和正在工作的音频图建立关联。

实际观察可以从进程查看器和模块导出表开始:先确认哪条进程加载了CoreAudioToolbox.dll等音频相关模块,再看可观察的转换器和 AudioUnit 接口。接着我们在AudioConverterNew 、AudioConverterNewSpecific记录每个转换器的 I/O ASBD,在AudioUnitSetProperty、AudioOutputUnitStart和AudioUnitRender记录图的格式、启动渲染时序。WASAPI 的IAudioClient和IAudioRenderClient是 COM 接口,应结合设备激活、GetService返回的接口对象观察方法调用。

找到候选之后,再通过播放、暂停、切歌和进度跳转观察对象如何变化,记录转换器地址及代次、所属线程、时间戳、输入输出格式、每次请求和实际产生的帧数,并与当前正在启动或渲染的 AudioUnit 对照。

初步观测后我们可以给 Apple Music 的音频链画一个简单的示意图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
受 Apple DRM 保护的 Compressed Audio Stream
↓
Apple AudioConverter 解码
↓
P0:解码器返回 PCM
↓
Apple AudioUnit 及相邻处理
↓
P1:AudioUnit渲染输出
↓
WASAPI 客户端缓冲
↓
P2:提交给Windows

P0、P1、P2 是这次研究中自定义的观测点,并不是 Apple 官方对内部结构的命名。它们的意义是把「我认为声音经过了这里」变成可以分别记录、比较和证伪的假设。

在流媒体 ALAC 路径里,P0 候选点是AudioConverterFillComplexBuffer调用原函数之后得到的解码输出。P1 观察AudioUnitRender的返回数据,P2 则观察 WASAPI 的GetBuffer/ReleaseBuffer提交边界。

为什么选择这个观察点?Apple 对 AudioConverterComplexInputDataProc的说明指出,转换器通过回调获得输入数据,并在AudioConverterFillComplexBuffer中输出转换结果。Apple:AudioConverterComplexInputDataProc

为什么 P0 是关键?

先看一次实际观察。在一首 96 kHz / 24-bit 立体声 ALAC 曲目的测试中,记录到的原生链路是:

1
2
3
4
5
6
P0:96 kHz ALAC/QLAC → 96 kHz LPCM,float32 表示
        ↓
活动 AudioUnit 输入:96 kHz LPCM
活动 AudioUnit 输出:384 kHz LPCM
        ↓
P2:384 kHz、立体声、IEEE float、WASAPI 共享模式

换句话说,这次测试里,96 kHz 到 384 kHz 的变化发生在 AudioUnit 输入与输出之间,或紧邻它的 Apple 内部处理阶段。P2 当时已经是 384 kHz 流,不能再把它当成原始 96 kHz 样本序列的逐帧副本。

更重要的问题是:P0 虽然看起来更靠前,它真的对应原始音乐样本吗?我用了三层证据。

第一层:确认它是解码输出,而不是另一个处理器

对于这条流媒体路径,观察到的转换器输入是 ALAC 或受保护的 QLAC,输出是同采样率的 LPCM。相对地,一个 LPCM → LPCM 转换器更可能是在做后续格式整理或采样率转换,不能仅凭它也输出 PCM 就称为解码边界。

每次捕获都需要结合转换器身份、输入输出 ASBD、实际产出帧数和活动音频图判断。这里的关键不是函数名看起来合适,而是对象的输入、输出和生命周期能够与当前播放关联。

第二层:检查样本是否仍落在 24-bit 整数格点上

这次 P0 使用 float32 表示,但音源有效精度为 24-bit。对于没有经过增益或其他处理的 24-bit 整数 PCM,归一化后的浮点值应当落在相应的 24-bit 整数格点上,并且可以准确映射回原来的整数值。

在一次 P0 聚合采样中,1,152,000 个样本里没有发现偏离该 24-bit 格点的值。这说明该窗口里的数据具备精确映射回 24-bit 整数的条件。

而在另一次冷启动观察中,按 AudioUnit 输出格式解释的 P1 长窗口有 4,608,000 个浮点样本,其中 3,662,971 个不在原来的 24-bit 格点上。结合该次观察中的 96 kHz 输入与 384 kHz 输出,这支持「P1 已经过后续处理」的判断。

第三层:与独立解码的同曲目参考逐样本比较

为了回答「是否真的相同」,我把 P0 与同一曲目、同一规格的独立 ALAC 参考解码结果对齐。

在一次新的采集中,连续 38,400 个立体声 P0 帧与参考 PCM 在确定的帧偏移处逐样本相等;另外 17 个各 19,200 帧的完整窗口也与参考匹配,比较结果没有样本误差。

这个结果比「有声音」或「格式一致」更强:对于这些已经捕获和对齐的窗口,P0 与独立参考解码得到了相同的样本值。

准确的说法是:P0 是本次探针能够稳定观察到、并通过参考样本验证的解码后 PCM 边界;对从这里接管输出这个目标而言,它是目前最有价值的入口。

P0 的 float32 是否影响精度?

第一次看到 P0 是 float32 时,很容易认为「浮点转整数一定会损失位深」。问题的关键是浮点数据里的值是什么,以及它从什么精度的音源而来。

以测试过的 24-bit 整数音源为例,把有符号整数样本记为 s,若 Apple 输出的浮点值正好是 s / 2²³,float32 可以精确表示这些归一化的 24-bit 样本。映射回使用 32-bit 容器的整数 PCM 时,可以把 24 位有效数据放在高位,低 8 位补零,而不改变音乐样本值。

这里改变的是表示方式:P0 的 float32 和输出端的 24-valid-in-32 整数,原始字节布局不同,但可以代表同一个 24-bit 样本。实现上必须检查有限值、数值范围、有效位对齐和往返映射;不能对任意浮点数据随便四舍五入后就宣布 bit-perfect。项目中的ExactSampleMapper.h就是在这个边界做严格映射。

拥有 P0 后如何接管并实现独占输出?

找到合适的取样点,只完成了一半。真正的播放器还必须处理线程、缓冲、播放状态以及设备所有权。

AMExclusive 的架构把界面控制和音频工作拆开:界面侧负责设置与状态;Broker 负责进程协调和通信;音频 Hook DLL 进入AMPLibraryAgent.exe,在目标进程内观察和处理音频。当前实现由 Broker 将模块加载到目标进程,再通过 MinHook 安装函数拦截。具体实现可以查看Broker.cpp和HookDll.cpp。

对于前面提到的流媒体 ALAC 路径,核心步骤可以概括为:

观察转换器的创建与格式,识别压缩音频到 LPCM 的候选对象。

结合活动 AudioUnit、播放生命周期和当前代次,确认候选对象属于正在播放的音频图。

调用 Apple 原来的AudioConverterFillComplexBuffer,在它返回后检查实际产生的 PCM,并复制进入预分配的有界队列。

在自己的音频线程里,从队列取出 PCM,做所需的精确样本表示映射,再交给 WASAPI 独占输出。

协调 Apple 原生输出端和新输出端的所有权,保证 Apple 的解码与播放调度能继续运行,同时避免两条路径同时向真实设备发声。

有一个容易忽略的细节:不能只看函数返回状态来判断是否有数据。Apple 的输入回调暂时没有新数据时,转换器可能一边返回非零状态,一边吐出内部已经解码的 PCM。项目遇到的轨迹里,385 次返回状态 3 的 P0 Fill 调用中,有 373 次仍带有非零输出。正确做法是保留 Apple 的原始返回状态,同时根据实际产生的帧数和缓冲合法性判断是否接收 PCM。Apple 的回调说明也描述了输入暂时耗尽时仍可能交付待输出数据的行为。

实时回调还不应该为了等待设备而长时间阻塞。项目使用有界队列将 Apple 的 PCM 生产和 WASAPI 消费分开;队列不足、溢出、对象失效以及旧曲目数据,都需要明确处理和记录。

为什么不直接搬走 Apple 的整个解码器?

早期尝试过让项目自己的工作线程复用 Apple 已经暴露的输入回调,再由另一个转换器拉取数据。它一开始可以得到 PCM,却在持续播放时出现供数停滞。

后来沿着调用链继续追查,发现输入回调只负责暴露当前队列记录并将其标记为已消费;真正推进队列头、更新状态和释放旧记录的动作,由外层 Apple 调度器完成。只拿回调和用户数据来用,却绕开外层调度事务,就可能反复碰到已经消费过的记录。

这个教训让最终方案转向更明确的分工:Apple 继续负责自己的网络、受保护媒体处理、队列、解码和调度;AMExclusive 在已验证的 PCM 边界复制数据,并接管物理输出。

为了让 Apple 的图继续按预期运行,项目为其下游提供软件侧的音频客户端、缓冲与时钟等调度接口;真正连接设备的是 AMExclusive 自己的独占 WASAPI 客户端。这样既不需要自行重写 Apple 的受保护媒体处理,也能避免原生共享输出和独占输出同时占用设备。(实现详见NativeRenderGateProxy.cpp)这就有点类似于所谓的“蓝色药丸”,Apple 还以为自己真的还在进行正常的共享输出,实际上它供给的只是一个我们构建的、虚拟的假端点,而真实情况是我们已经把解码后 PCM 复制走,进行独占输出了。

本地文件的 P0 不一定是同一个函数出口

上面的 ALAC/QLAC → LPCM 例子主要对应测试过的流媒体路径。普通导入的本地音频还可能先暴露一个 LPCM → LPCM 转换器,这时项目需要观察它输入侧、Apple 后续工作格式转换之前的 PCM;对于部分 AAC 路径,也存在通过类似桥接边界取数据的情况。

所以,P0 更适合被理解成一个逻辑边界:目前能够确认、并适合接管的解码后或后续转换前 PCM。

如何验证接管后的输出没有改变音乐?

验证需要回答两个问题:P0 是否等于参考音源,以及 AMExclusive 最终提交给 WASAPI 的音乐样本是否仍等于参考。

后者不能只看IsFormatSupported返回成功、播放器显示了「独占」、DAC 亮起了 96 kHz,或者程序自己的校验计数器为零。这些信息有用,但不足以单独证明整条样本序列相同。

在一次较早的 96 kHz / 24-bit 实验构建中,我把最终提交给 WASAPIIAudioRenderClient::ReleaseBuffer之前的整数 PCM 记录下来,与独立解码的同曲目 ALAC 参考比较:

整首歌曲的 20,107,520 个立体声帧逐字节相等,没有发现第一个不匹配位置。

按约定的 PCM32 容器表示,两边音乐部分的 SHA-256 都是 e5a0e305046b2df8623e04ada7e74a85cf5818186e18cf7129917877235c6a5b。

该次流媒体测试还跨过了自然单曲循环边界:后续 3,554,560 帧与参考开头逐字节相等。

在这个验收区间内,记录到的丢帧、缓冲不足和 WASAPI 缓冲提交失败计数均为零。

普通导入的本地 96 kHz / 24-bit ALAC 路径也做过单独的整曲比较。

当前项目的目标格式协商会调用IAudioClient::IsFormatSupported,并以AUDCLNT_SHAREMODE_EXCLUSIVE初始化设备;请求的格式必须由设备支持,初始化仍可能失败。Microsoft:设备格式

独占之外,音质锁

P0 接管能决定已经选中的音频如何输出,却不能决定 Apple Music 一开始下载的是哪一种音源。

假设同一首歌有 48 kHz 和 96 kHz 的 ALAC 候选版本。用户打开高解析度无损,并不意味着仅仅设置「最高允许 192 kHz」就一定会选中 96 kHz:48 kHz 同样低于这个上限。如果播放器的候选选择逻辑综合了带宽等条件,低规格版本仍可能被留下。

这就是 AMExclusive 音质锁处理的问题:在符合用户无损设置的前提下,让高解析度无损的候选选择不要因为带宽等回退路径,悄悄降到已知的较低无损规格或有损版本。

首先,它读取 Apple Music 自己的音质偏好,并观察播放器设置采样率上限、音源偏好和创建无损候选过滤器的过程。对已知的高解析度无损设置,项目会恢复 192 kHz 的候选上限,并调整与无损立体声有关的偏好。

然后,在高解析度无损策略下,它从当前过滤评估拿到的原始候选列表中,查找 Apple 原有无损类型判断所允许、且声明采样率不超过 192 kHz 的最高采样率。例如:

1
2
3
4
5
原始候选:48 kHz ALAC、96 kHz ALAC、256 kbps AAC
高解析度策略观察到的最高无损采样率:96 kHz

如果原生过滤结果只剩 48 kHz ALAC 或 AAC:
    严格过滤后的结果可以为空,而不是把它们当成 96 kHz 继续播放

实现上,项目包装 Apple 的过滤器,并在原生过滤树处理后,对返回结果再做一次严格条件的交集。这个过程只能从 Apple 返回的结果中移除不符合策略的候选,不能凭空添加一个 Apple 没有返回的音源。如果严格结果为空,播放器可能继续等待,也可能出现加载失败或其他错误。

对于普通无损档位,当前严格过滤主要约束无损编码类型,并不像高解析度档位那样执行上述最高声明采样率选择。无损开关关闭、用户选择有损档位或设置无法确认时,相关逻辑交还给 Apple 原有行为。

如果某首曲目的原始候选清单根本没有 ALAC/QLAC,音质锁会保留 Apple 原来的 AAC-only 选择,而不是让这首歌永久卡在空候选集合里。相反,如果清单里存在无损候选,只是它因为网络或其他过滤条件暂时没有通过,就不能把这种情况简单当成「这首歌只有 AAC」。

这些处理针对的是选源,不会把 48 kHz 歌曲升采样到 96 或 192 kHz,也不能创造目录中不存在的高解析度版本。192 kHz 是高解析度候选的上限,不是每首歌的目标输出采样率;当前最高采样率选择也没有对同采样率的 16-bit 和 24-bit 候选再做位深优先级比较。(实现详见LosslessQualityLock.cpp)

最后

没有最后。AMExclusive 仍是一个非常早期且正在不断进化的项目,相信未来会有更多人享受到更高质量的流媒体音乐体验。