在 macOS 上同时处理音频输入输出、视频采集和网络流时,最麻烦的往往不是缓冲区大小或采样率设置,而是各个硬件时钟各走各的。USB 声卡有自己的晶振,视频设备跟随显示链路,网络时间协议又返回一个绝对时间戳,如果直接把这些值放进同一个处理循环,漂移会很快表现为爆音、重复采样或音画不同步。CAClock 正是 Core Audio 提供的一层时钟抽象,它把主机时间、音频设备时间、时间码和时间源统一到一套接口里,开发者可以读取不同时钟基准下的当前时刻,也可以设置同步源让外部时间码驱动音频时钟。

很多开发者会直接拿 mach_absolute_time 当作音频时间戳,或者把 NTP 秒数直接减去设备时间得到一个固定偏移,这些做法在短时间运行可能看不出问题,一旦设备运行超过几分钟,晶振误差就会累积到毫秒级。理解 CAClock 的 timebase、time source 和 sync source 三个概念,是写出稳定同步逻辑的前提。
一、CAClock 的时间模型:先分清 timebase 和 sync source
CAClock 并不像普通计时器那样只返回一个数字。它使用 CAClockTime 结构来描述当前时刻,这个结构里最重要的字段是 timebase,它决定了联合体 time 中哪个成员有效。当 timebase 为 kCAClockTimebase_HostTime 时,应该读取 time.hostTime;当 timebase 为 kCAClockTimebase_AudioDevice 时,应该读取 time.audioDeviceTime。如果没有判断 timebase 就直接读取 union,很容易拿到错误数据。
CAClock 的属性可以分成两类:一类是描述时钟自身基准的 internal timebase,另一类是描述信号来源的 time source 与 sync source。Internal timebase 决定 CAClock 计时走的是主机时钟、音频设备时钟还是时间码;time source 表示读取当前时间时参考哪一个时间源;sync source 则表示是否锁定到外部 SMPTE、MIDI Clock 或音频设备同步信号。初学者很容易把 time source 和 sync source 混为一谈,实际上前者影响当前时间读数,后者影响锁相行为。
下面这段代码展示了最基础的 CAClock 创建与主机时间读取。代码看起来简单,但它是后续所有时钟映射的基础。
#include <AudioToolbox/AudioToolbox.h>
void CreateCAClockAndReadHostTime(void) {
CAClockRef clock = NULL;
CAClockTime clockTime;
OSStatus status = CAClockNew(kCFAllocatorDefault, &clock);
if (status != noErr) {
return;
}
status = CAClockGetTime(clock, &clockTime);
if (status == noErr && clockTime.timebase == kCAClockTimebase_HostTime) {
uint64_t hostTicks = clockTime.time.hostTime;
printf("host ticks: %llu\n", hostTicks);
}
CAClockDispose(clock);
}
在这个例子里没有设置任何属性,因此 CAClock 使用默认的内部时间基准。实际开发中最好显式设置 internal timebase,避免系统的默认配置因为音频设备插拔而变化。
二、绑定音频设备:让 CAClock 直接返回设备时间
要让 CAClock 与真实的声卡关联,首先需要获得 AudioDeviceID。macOS 的 Core Audio 硬件抽象用 AudioObject 属性来查找默认设备,这与较老的 AudioHardwareGetProperty 接口不同。下面的代码通过 AudioObjectGetPropertyData 获取默认输出设备,再把这个设备设置给 CAClock。
AudioObjectPropertyAddress address = {
kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
AudioDeviceID deviceID = 0;
UInt32 size = sizeof(deviceID);
OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject,
&address,
0,
NULL,
&size,
&deviceID);
if (status != noErr) return;
CAClockTimebase timebase = kCAClockTimebase_AudioDevice;
CAClockSetProperty(clock, kCAClockProperty_InternalTimebase, sizeof(timebase), &timebase);
UInt32 timeSource = kCAClockTimeSource_AudioDevice;
CAClockSetProperty(clock, kCAClockProperty_TimeSource, sizeof(timeSource), &timeSource);
设置完成后,CAClockGetTime 返回的 timebase 就会变成 kCAClockTimebase_AudioDevice,同时 time.audioDeviceTime 保存的是以秒为单位的设备时间。这个时间不是主机开机以来经过的秒数,而是音频设备自身的采样时间,它会随着设备晶振独立推进。
CAClockTime deviceTime;
status = CAClockGetTime(clock, &deviceTime);
if (status == noErr && deviceTime.timebase == kCAClockTimebase_AudioDevice) {
Float64 deviceSeconds = deviceTime.time.audioDeviceTime;
printf("audio device time: %.6f seconds\n", deviceSeconds);
}
主机时钟和音频设备时钟的差异是很多音画不同步问题的根源。主机时钟由 CPU 晶振或系统时钟驱动,音频设备时钟由声卡晶振驱动,两者之间通常存在一个很小的比例差。如果一直用主机时钟去切分音频缓冲,缓冲区会逐渐偏离真实的设备采样位置,表现为周期性爆音或缓冲欠载。通过 CAClock 读取设备时间,可以在应用层建立主机时间和设备时间之间的实时映射,而不是假设两者相同。
三、把视频时钟和 NTP 映射到同一时间轴
视频时钟在 macOS 上通常由显示链路驱动,比如 CVDisplayLink 或 CADisplayLink 会周期回调,给出基于主机时间的刷新时间。NTP 则返回自 1900 年或 1970 年以来的秒数,本身是一个绝对时间尺度。要把视频帧、网络时间戳和音频缓冲对齐,常见做法是先选主机时间作为中间轴,再分别建立音频设备时间和 NTP 时间到主机时间的映射。
NTP 的映射相对简单。客户端在收到 NTP 响应时,同时读取当前主机时间,计算 NTP 时间与主机秒数之间的偏移量。之后任意时刻只要知道主机时间,就能估算对应的 NTP 时间。下面的结构体用于保存这个锚点,函数负责完成转换。
typedef struct {
uint64_t hostTicks;
Float64 ntpSeconds;
} NTPAnchor;
static NTPAnchor gAnchor;
static Float64 HostTicksToSeconds(uint64_t hostTicks) {
return (Float64)AudioConvertHostTimeToNanos(hostTicks) * 1.0e-9;
}
void SetNTPAnchor(uint64_t hostTicks, Float64 ntpSeconds) {
gAnchor.hostTicks = hostTicks;
gAnchor.ntpSeconds = ntpSeconds;
}
Float64 EstimateNTPFromHost(uint64_t hostTicks) {
Float64 hostDelta = HostTicksToSeconds(hostTicks - gAnchor.hostTicks);
return gAnchor.ntpSeconds + hostDelta;
}
音频设备时间到主机时间的映射则要利用 CAClock 同时读取两个时间基准。可以先在某个瞬间读取主机时间,再读取 CAClock 的音频设备时间,把这两个值记下来。之后只要得到主机时间,就能按比例估算音频设备时间。需要注意的是这个比例不是固定 1.0,长时间运行后会因为晶振误差产生偏移,因此应该周期性地重新采样更新锚点。
static volatile uint64_t gHostAnchor = 0;
static volatile Float64 gAudioDeviceTimeAtHostAnchor = 0.0;
void UpdateClockAnchor(CAClockRef clock) {
uint64_t hostNow = AudioGetCurrentHostTime();
CAClockTime t;
OSStatus status = CAClockGetTime(clock, &t);
if (status == noErr && t.timebase == kCAClockTimebase_AudioDevice) {
gAudioDeviceTimeAtHostAnchor = t.time.audioDeviceTime;
gHostAnchor = hostNow;
}
}
static Float64 HostTicksToDeviceSeconds(uint64_t hostTicks) {
if (gHostAnchor == 0) return 0.0;
Float64 hostDelta = (Float64)AudioConvertHostTimeToNanos(hostTicks - gHostAnchor) * 1.0e-9;
return gAudioDeviceTimeAtHostAnchor + hostDelta;
}
把这两个映射组合起来,就形成了一条完整的时间转换链:NTP 时间先转成主机时间,主机时间再转成音频设备时间。视频帧的显示时间通常也可以归一到主机时间上,这样音视频和外部网络时间戳就能在同一个坐标系里比较。对于需要严格网络同步的音频应用,还可以在锚点更新时加入指数移动平均或 PLL 滤波,减小网络抖动带来的跳变。
四、完整同步流程与工程落地的注意点
实际落地时,同步链路通常由一个后台线程维护,而不是在音频渲染回调里临时计算。CAClock 的 CAClockGetTime 虽然接口简单,但内部仍可能涉及锁或属性查询,把它放进实时音频线程可能造成优先级倒置。更好的做法是在渲染回调中只读取已经更新好的锚点变量,虽然简单的 volatile 读取在严格并发语义下不够完美,但在很多音频工程中配合原子操作已经足够使用。
后台线程的更新频率取决于设备之间的漂移速度。一般来说每秒更新一次足够应付大多数 USB 声卡,但如果设备晶振质量较差,或者要求长时间保持样本级同步,可以把更新间隔缩短到 200 毫秒。更新时先读取主机时间,再读取 CAClock 设备时间,两个读取之间不要插入可能阻塞的操作。更新完成后,渲染回调便可以直接通过主机时间计算出音频设备时间和 NTP 时间。
还有一个容易忽略的问题:CAClock 设置 time source 或 sync source 后,可能会触发音频设备时钟源切换或者短暂的计时中断。这类调用不应该在播放过程中频繁执行,而应该在初始化阶段完成。如果需要在运行中切换,应先暂停音频流,切换完成后再恢复。对于真正的硬件级同步,比如让声卡锁定到外部 ADAT、SPDIF 或 Word Clock,则需要使用 AudioDeviceSetProperty 或 AudioObjectSetPropertyData 配置设备本身的时钟域,CAClock 负责的是软件层面的时间表示与读取,不能替代硬件锁相环。
NTP 时间也不建议直接用来驱动音频渲染时钟。网络时间戳的精度通常在毫秒级,而音频回调可能每 128 帧就执行一次,在 48kHz 下每次间隔只有约 2.67 毫秒。把 NTP 直接当作采样时间源会引入明显的抖晃。正确做法是让音频设备晶振作为短时基准,NTP 只负责定期修正长时漂移,这样既保证了本地音频流的平滑性,又能在较长时间尺度上贴合外部时间参考。
CAClockCore Audio时钟同步修改时间:2026-09-25 09:38:58