[{"content":"背景，现状\rApple Music，苹果公司互联网业务的一颗明珠。其成功固然离不开与健全的苹果硬件生态的融合，但同时，苹果直接直接与唱片公司对接拿到母带，并以此优势达到流媒体音频质量第一梯队，因此在音乐发烧友群体，Apple Music 的声誉也非常高。\n然而，Apple Music 的音频质量优势在音源阶段后戛然而止。在The Computer Audiophile — Apple Music\u0026rsquo;s Lossless and Hi-Res Mess，The Computer Audiophile — Apple Music\u0026rsquo;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 端，这一情况则更加糟糕。接下来，我们就要尝试改善这一情况。\n拆解，开盒\r第一步：在不改变播放结果的前提下，观察不同位置的格式和数据。\n（本轮分析主要基于 Windows 版 Apple Music 文件版本 1.6.4.90，包及框架版本 1.1540.23042.0， x86-64）\n先确定进程边界。Apple Music 的界面进程与承担媒体工作的AMPLibraryAgent.exe是分开的，因此我们需要观察 AMP 中与音频有关的模块、导出函数、对象创建和生命周期。以下是重点记录信息：\nAudioConverter的输入和输出格式：编码类型、采样率、声道数、每帧字节数、整数或浮点。 AudioUnit的输入输出格式，初始化、启动、渲染调用。 WASAPI 的IAudioClient::Initialize参数、共享或独占，以及IAudioRenderClient提交的缓冲。 对象标识、调用顺序、线程、时间戳、实际产生的帧数和曲目切换之生命周期。 格式信息通常由AudioStreamBasicDescription描述，以下简称ASBD。它可以告诉我们一个对象输入、输出的数据是什么样子，但不会自动告诉我们这是不是当前正在听的歌曲。播放器可能同时预取出下一首、保留旧对象、创建多个转换器，因此还要把转换器和正在工作的音频图建立关联。\n实际观察可以从进程查看器和模块导出表开始：先确认哪条进程加载了CoreAudioToolbox.dll等音频相关模块，再看可观察的转换器和 AudioUnit 接口。接着我们在AudioConverterNew 、AudioConverterNewSpecific记录每个转换器的 I/O ASBD，在AudioUnitSetProperty、AudioOutputUnitStart和AudioUnitRender记录图的格式、启动渲染时序。WASAPI 的IAudioClient和IAudioRenderClient是 COM 接口，应结合设备激活、GetService返回的接口对象观察方法调用。\n找到候选之后，再通过播放、暂停、切歌和进度跳转观察对象如何变化，记录转换器地址及代次、所属线程、时间戳、输入输出格式、每次请求和实际产生的帧数，并与当前正在启动或渲染的 AudioUnit 对照。\n初步观测后我们可以给 Apple Music 的音频链画一个简单的示意图：\n1 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 官方对内部结构的命名。它们的意义是把「我认为声音经过了这里」变成可以分别记录、比较和证伪的假设。\n在流媒体 ALAC 路径里，P0 候选点是AudioConverterFillComplexBuffer调用原函数之后得到的解码输出。P1 观察AudioUnitRender的返回数据，P2 则观察 WASAPI 的GetBuffer/ReleaseBuffer提交边界。\n为什么选择这个观察点？Apple 对 AudioConverterComplexInputDataProc的说明指出，转换器通过回调获得输入数据，并在AudioConverterFillComplexBuffer中输出转换结果。Apple：AudioConverterComplexInputDataProc\n为什么 P0 是关键？\r先看一次实际观察。在一首 96 kHz / 24-bit 立体声 ALAC 曲目的测试中，记录到的原生链路是：\n1 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 样本序列的逐帧副本。\n更重要的问题是：P0 虽然看起来更靠前，它真的对应原始音乐样本吗？我用了三层证据。\n第一层：确认它是解码输出，而不是另一个处理器\r对于这条流媒体路径，观察到的转换器输入是 ALAC 或受保护的 QLAC，输出是同采样率的 LPCM。相对地，一个 LPCM → LPCM 转换器更可能是在做后续格式整理或采样率转换，不能仅凭它也输出 PCM 就称为解码边界。\n每次捕获都需要结合转换器身份、输入输出 ASBD、实际产出帧数和活动音频图判断。这里的关键不是函数名看起来合适，而是对象的输入、输出和生命周期能够与当前播放关联。\n第二层：检查样本是否仍落在 24-bit 整数格点上\r这次 P0 使用 float32 表示，但音源有效精度为 24-bit。对于没有经过增益或其他处理的 24-bit 整数 PCM，归一化后的浮点值应当落在相应的 24-bit 整数格点上，并且可以准确映射回原来的整数值。\n在一次 P0 聚合采样中，1,152,000 个样本里没有发现偏离该 24-bit 格点的值。这说明该窗口里的数据具备精确映射回 24-bit 整数的条件。\n而在另一次冷启动观察中，按 AudioUnit 输出格式解释的 P1 长窗口有 4,608,000 个浮点样本，其中 3,662,971 个不在原来的 24-bit 格点上。结合该次观察中的 96 kHz 输入与 384 kHz 输出，这支持「P1 已经过后续处理」的判断。\n第三层：与独立解码的同曲目参考逐样本比较\r为了回答「是否真的相同」，我把 P0 与同一曲目、同一规格的独立 ALAC 参考解码结果对齐。\n在一次新的采集中，连续 38,400 个立体声 P0 帧与参考 PCM 在确定的帧偏移处逐样本相等；另外 17 个各 19,200 帧的完整窗口也与参考匹配，比较结果没有样本误差。\n这个结果比「有声音」或「格式一致」更强：对于这些已经捕获和对齐的窗口，P0 与独立参考解码得到了相同的样本值。\n准确的说法是：P0 是本次探针能够稳定观察到、并通过参考样本验证的解码后 PCM 边界；对从这里接管输出这个目标而言，它是目前最有价值的入口。\nP0 的 float32 是否影响精度？\r第一次看到 P0 是 float32 时，很容易认为「浮点转整数一定会损失位深」。问题的关键是浮点数据里的值是什么，以及它从什么精度的音源而来。\n以测试过的 24-bit 整数音源为例，把有符号整数样本记为 s，若 Apple 输出的浮点值正好是 s / 2²³，float32 可以精确表示这些归一化的 24-bit 样本。映射回使用 32-bit 容器的整数 PCM 时，可以把 24 位有效数据放在高位，低 8 位补零，而不改变音乐样本值。\n这里改变的是表示方式：P0 的 float32 和输出端的 24-valid-in-32 整数，原始字节布局不同，但可以代表同一个 24-bit 样本。实现上必须检查有限值、数值范围、有效位对齐和往返映射；不能对任意浮点数据随便四舍五入后就宣布 bit-perfect。项目中的ExactSampleMapper.h就是在这个边界做严格映射。\n拥有 P0 后如何接管并实现独占输出？\r找到合适的取样点，只完成了一半。真正的播放器还必须处理线程、缓冲、播放状态以及设备所有权。\nAMExclusive 的架构把界面控制和音频工作拆开：界面侧负责设置与状态；Broker 负责进程协调和通信；音频 Hook DLL 进入AMPLibraryAgent.exe，在目标进程内观察和处理音频。当前实现由 Broker 将模块加载到目标进程，再通过 MinHook 安装函数拦截。具体实现可以查看Broker.cpp和HookDll.cpp。\n对于前面提到的流媒体 ALAC 路径，核心步骤可以概括为：\n观察转换器的创建与格式，识别压缩音频到 LPCM 的候选对象。\n结合活动 AudioUnit、播放生命周期和当前代次，确认候选对象属于正在播放的音频图。\n调用 Apple 原来的AudioConverterFillComplexBuffer，在它返回后检查实际产生的 PCM，并复制进入预分配的有界队列。\n在自己的音频线程里，从队列取出 PCM，做所需的精确样本表示映射，再交给 WASAPI 独占输出。\n协调 Apple 原生输出端和新输出端的所有权，保证 Apple 的解码与播放调度能继续运行，同时避免两条路径同时向真实设备发声。\n有一个容易忽略的细节：不能只看函数返回状态来判断是否有数据。Apple 的输入回调暂时没有新数据时，转换器可能一边返回非零状态，一边吐出内部已经解码的 PCM。项目遇到的轨迹里，385 次返回状态 3 的 P0 Fill 调用中，有 373 次仍带有非零输出。正确做法是保留 Apple 的原始返回状态，同时根据实际产生的帧数和缓冲合法性判断是否接收 PCM。Apple 的回调说明也描述了输入暂时耗尽时仍可能交付待输出数据的行为。\n实时回调还不应该为了等待设备而长时间阻塞。项目使用有界队列将 Apple 的 PCM 生产和 WASAPI 消费分开；队列不足、溢出、对象失效以及旧曲目数据，都需要明确处理和记录。\n为什么不直接搬走 Apple 的整个解码器？\r早期尝试过让项目自己的工作线程复用 Apple 已经暴露的输入回调，再由另一个转换器拉取数据。它一开始可以得到 PCM，却在持续播放时出现供数停滞。\n后来沿着调用链继续追查，发现输入回调只负责暴露当前队列记录并将其标记为已消费；真正推进队列头、更新状态和释放旧记录的动作，由外层 Apple 调度器完成。只拿回调和用户数据来用，却绕开外层调度事务，就可能反复碰到已经消费过的记录。\n这个教训让最终方案转向更明确的分工：Apple 继续负责自己的网络、受保护媒体处理、队列、解码和调度；AMExclusive 在已验证的 PCM 边界复制数据，并接管物理输出。\n为了让 Apple 的图继续按预期运行，项目为其下游提供软件侧的音频客户端、缓冲与时钟等调度接口；真正连接设备的是 AMExclusive 自己的独占 WASAPI 客户端。这样既不需要自行重写 Apple 的受保护媒体处理，也能避免原生共享输出和独占输出同时占用设备。（实现详见NativeRenderGateProxy.cpp）这就有点类似于所谓的“蓝色药丸”，Apple 还以为自己真的还在进行正常的共享输出，实际上它供给的只是一个我们构建的、虚拟的假端点，而真实情况是我们已经把解码后 PCM 复制走，进行独占输出了。\n本地文件的 P0 不一定是同一个函数出口\r上面的 ALAC/QLAC → LPCM 例子主要对应测试过的流媒体路径。普通导入的本地音频还可能先暴露一个 LPCM → LPCM 转换器，这时项目需要观察它输入侧、Apple 后续工作格式转换之前的 PCM；对于部分 AAC 路径，也存在通过类似桥接边界取数据的情况。\n所以，P0 更适合被理解成一个逻辑边界：目前能够确认、并适合接管的解码后或后续转换前 PCM。\n如何验证接管后的输出没有改变音乐？\r验证需要回答两个问题：P0 是否等于参考音源，以及 AMExclusive 最终提交给 WASAPI 的音乐样本是否仍等于参考。\n后者不能只看IsFormatSupported返回成功、播放器显示了「独占」、DAC 亮起了 96 kHz，或者程序自己的校验计数器为零。这些信息有用，但不足以单独证明整条样本序列相同。\n在一次较早的 96 kHz / 24-bit 实验构建中，我把最终提交给 WASAPIIAudioRenderClient::ReleaseBuffer之前的整数 PCM 记录下来，与独立解码的同曲目 ALAC 参考比较：\n整首歌曲的 20,107,520 个立体声帧逐字节相等，没有发现第一个不匹配位置。\n按约定的 PCM32 容器表示，两边音乐部分的 SHA-256 都是 e5a0e305046b2df8623e04ada7e74a85cf5818186e18cf7129917877235c6a5b。\n该次流媒体测试还跨过了自然单曲循环边界：后续 3,554,560 帧与参考开头逐字节相等。\n在这个验收区间内，记录到的丢帧、缓冲不足和 WASAPI 缓冲提交失败计数均为零。\n普通导入的本地 96 kHz / 24-bit ALAC 路径也做过单独的整曲比较。\n当前项目的目标格式协商会调用IAudioClient::IsFormatSupported，并以AUDCLNT_SHAREMODE_EXCLUSIVE初始化设备；请求的格式必须由设备支持，初始化仍可能失败。Microsoft：设备格式\n独占之外，音质锁\rP0 接管能决定已经选中的音频如何输出，却不能决定 Apple Music 一开始下载的是哪一种音源。\n假设同一首歌有 48 kHz 和 96 kHz 的 ALAC 候选版本。用户打开高解析度无损，并不意味着仅仅设置「最高允许 192 kHz」就一定会选中 96 kHz：48 kHz 同样低于这个上限。如果播放器的候选选择逻辑综合了带宽等条件，低规格版本仍可能被留下。\n这就是 AMExclusive 音质锁处理的问题：在符合用户无损设置的前提下，让高解析度无损的候选选择不要因为带宽等回退路径，悄悄降到已知的较低无损规格或有损版本。\n首先，它读取 Apple Music 自己的音质偏好，并观察播放器设置采样率上限、音源偏好和创建无损候选过滤器的过程。对已知的高解析度无损设置，项目会恢复 192 kHz 的候选上限，并调整与无损立体声有关的偏好。\n然后，在高解析度无损策略下，它从当前过滤评估拿到的原始候选列表中，查找 Apple 原有无损类型判断所允许、且声明采样率不超过 192 kHz 的最高采样率。例如：\n1 2 3 4 5 原始候选：48 kHz ALAC、96 kHz ALAC、256 kbps AAC 高解析度策略观察到的最高无损采样率：96 kHz 如果原生过滤结果只剩 48 kHz ALAC 或 AAC： 严格过滤后的结果可以为空，而不是把它们当成 96 kHz 继续播放 实现上，项目包装 Apple 的过滤器，并在原生过滤树处理后，对返回结果再做一次严格条件的交集。这个过程只能从 Apple 返回的结果中移除不符合策略的候选，不能凭空添加一个 Apple 没有返回的音源。如果严格结果为空，播放器可能继续等待，也可能出现加载失败或其他错误。\n对于普通无损档位，当前严格过滤主要约束无损编码类型，并不像高解析度档位那样执行上述最高声明采样率选择。无损开关关闭、用户选择有损档位或设置无法确认时，相关逻辑交还给 Apple 原有行为。\n如果某首曲目的原始候选清单根本没有 ALAC/QLAC，音质锁会保留 Apple 原来的 AAC-only 选择，而不是让这首歌永久卡在空候选集合里。相反，如果清单里存在无损候选，只是它因为网络或其他过滤条件暂时没有通过，就不能把这种情况简单当成「这首歌只有 AAC」。\n这些处理针对的是选源，不会把 48 kHz 歌曲升采样到 96 或 192 kHz，也不能创造目录中不存在的高解析度版本。192 kHz 是高解析度候选的上限，不是每首歌的目标输出采样率；当前最高采样率选择也没有对同采样率的 16-bit 和 24-bit 候选再做位深优先级比较。（实现详见LosslessQualityLock.cpp）\n最后\r没有最后。AMExclusive 仍是一个非常早期且正在不断进化的项目，相信未来会有更多人享受到更高质量的流媒体音乐体验。\n","date":"2026-11-11T15:00:00+08:00","image":"/uploads/cms/ame_logo_76fa6bd654.png","permalink":"/2026/11/11/about-amexclusive/","title":"AMExclusive，如何给予 Windows 端 Apple Music 独占输出的能力"},{"content":"群号：932738927\n","date":"2025-10-21T11:45:15+08:00","permalink":"/2025/10/21/about-mrss-qq/","title":"关于MRSS交流群"},{"content":"\n","date":"2025-10-19T16:37:28+08:00","permalink":"/2025/10/19/support-me/","title":"关于打赏"}]