Core Audio 的实时音频输出链路里,有一个很容易被误解的环节:系统并不是通过某个定时器把音频数据推送给应用,而是直接调用你注册的回调函数来索取下一批样本。这个回调在 macOS 上通常通过 AURenderCallbackStruct 挂到输出 AudioUnit 上。它的执行线程、调用时机以及返回样本的速度,共同决定了一条音频链路是否稳定、低延迟且没有爆音。

要想真正用好这个机制,不能只把回调函数写完就结束,还需要理解缓冲区的布局、作用域的含义以及实时线程下的硬性限制。下面先从一个完整的输出单元创建流程开始,再逐步展开回调内部的处理逻辑。
一、创建输出 AudioUnit 并注册 AURenderCallbackStruct
macOS 上最常见的输出单元类型是 kAudioUnitSubType_DefaultOutput。创建它之后,需要先把输入作用域的流格式设置成线性 PCM,告诉 AudioUnit 你的回调会提供什么样的样本数据。下面的代码展示了创建、格式设置、回调注册以及启动的完整过程:
#include <AudioUnit/AudioUnit.h>
#include <AudioToolbox/AudioToolbox.h>
static OSStatus renderCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData);
void prepareOutputUnit(AudioUnit *outUnit)
{
AudioComponentDescription desc = {0};
desc.componentType = kAudioUnitType_Output;
desc.componentSubType = kAudioUnitSubType_DefaultOutput;
desc.componentManufacturer = kAudioUnitManufacturer_Apple;
AudioComponent component = AudioComponentFindNext(NULL, &desc);
AudioComponentInstanceNew(component, outUnit);
AudioStreamBasicDescription format = {0};
format.mSampleRate = 44100.0;
format.mFormatID = kAudioFormatLinearPCM;
format.mFormatFlags = kAudioFormatFlagIsFloat |
kAudioFormatFlagIsPacked |
kAudioFormatFlagIsNonInterleaved;
format.mBitsPerChannel = 32;
format.mChannelsPerFrame = 2;
format.mFramesPerPacket = 1;
format.mBytesPerFrame = format.mBitsPerChannel / 8 * format.mChannelsPerFrame;
format.mBytesPerPacket = format.mBytesPerFrame;
AudioUnitSetProperty(*outUnit,
kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Input,
0,
&format,
sizeof(format));
AURenderCallbackStruct callbackStruct;
callbackStruct.inputProc = renderCallback;
callbackStruct.inputProcRefCon = NULL;
AudioUnitSetProperty(*outUnit,
kAudioUnitProperty_SetRenderCallback,
kAudioUnitScope_Input,
0,
&callbackStruct,
sizeof(callbackStruct));
AudioUnitInitialize(*outUnit);
AudioOutputUnitStart(*outUnit);
}
上面的代码里,AURenderCallbackStruct 只包含两个成员:inputProc 和 inputProcRefCon。第一个成员是函数指针,第二个成员是透传给回调的上下文指针。这里必须强调,注册回调时使用的是 kAudioUnitScope_Input 作用域,而不是输出作用域。输出单元的音频流方向是输入侧接收数据,再送到内部输出设备,因此自定义渲染回调要挂在输入总线上。
格式设置中的 kAudioFormatFlagIsNonInterleaved 让每个声道拥有独立的缓冲区,这样在回调里可以直接取得左声道和右声道两个 float 指针。如果省略这个标志并采用交错格式,AudioBufferList 通常只包含一个缓冲区,样本以左右左右的方式排列,处理方式会稍有不同。对于实时效果器和合成器,非交错格式通常更容易编写。
二、渲染回调函数内部:填充 AudioBufferList 的两种方式
回调函数是实时音频链路的核心。假设应用需要生成一个 440Hz 的正弦波并输出到左右声道,回调代码如下:
#include <math.h>
typedef struct {
double phase;
double sampleRate;
} SynthContext;
static OSStatus renderCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
{
SynthContext *ctx = (SynthContext *)inRefCon;
float *left = (float *)ioData->mBuffers[0].mData;
float *right = (float *)ioData->mBuffers[1].mData;
for (UInt32 frame = 0; frame < inNumberFrames; ++frame) {
double sample = sin(2.0 * M_PI * 440.0 * ctx->phase);
ctx->phase += 1.0 / ctx->sampleRate;
if (ctx->phase >= 1.0) {
ctx->phase -= 1.0;
}
left[frame] = (float)(sample * 0.2);
right[frame] = left[frame];
}
return noErr;
}
这个例子直接合成样本。每个回调周期内,inNumberFrames 表示系统本次需要多少帧数据,通常由音频设备缓冲区大小决定,例如 128 或 512 帧。函数必须在返回前把 ioData 中的每个缓冲区填满,否则残留数据会造成噪声或重复采样。因为格式是非交错的,mBuffers[0] 和 mBuffers[1] 分别代表左右声道,且每个缓冲区的数据长度是 inNumberFrames * sizeof(float)。
另一种常见需求是从上游 AudioUnit 拉取数据后实时处理,例如在混音器和输出单元之间插入一个限幅器。这个场景下,回调可以通过 AudioUnitRender 请求上游单元填充同一个 ioData,再对样本做增益控制:
typedef struct {
AudioUnit upstreamUnit;
float gain;
} EffectContext;
static OSStatus renderCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
{
EffectContext *ctx = (EffectContext *)inRefCon;
OSStatus status = AudioUnitRender(ctx->upstreamUnit,
ioActionFlags,
inTimeStamp,
0,
inNumberFrames,
ioData);
if (status != noErr) {
return status;
}
float *left = (float *)ioData->mBuffers[0].mData;
float *right = (float *)ioData->mBuffers[1].mData;
for (UInt32 frame = 0; frame < inNumberFrames; ++frame) {
left[frame] *= ctx->gain;
right[frame] *= ctx->gain;
}
return noErr;
}
这里 AudioUnitRender 的第一个参数是上游单元实例,第二个和第三个参数原样透传回调收到的 ioActionFlags 和 inTimeStamp。调用成功后,ioData 中已经是上游节点混合后的 PCM 数据,随后遍历每个声道并乘以增益即可。需要注意的是,不要在回调内部试图重新初始化 AudioUnit、修改图结构或调用可能导致阻塞的 API,这些操作会破坏实时线程的确定性。
三、实时线程约束:为什么不能在回调里做普通操作
渲染回调运行在系统音频 I/O 的实时线程上。这个线程的优先级很高,并且通常被系统设置为固定优先级,以保证音频数据连续供应。任何一次内存分配、锁等待、文件读写或系统调用,都可能让回调的执行时间超出允许的截止时间,从而导致爆音甚至设备重启流程。
具体来说,以下操作在回调中应当避免:使用 malloc、new、NSLog、printf、fopen、pthread_mutex_lock 等。Objective-C 消息发送也要格外小心,因为很多常见的 Cocoa 方法可能触发分配或同步操作。假如需要把主程序中的音量、频率等参数传给回调,最稳妥的做法是使用 C++ 的 std::atomic 变量或 C11 原子操作。下面是一个简单的共享状态:
#include <atomic>
struct SharedAudioState {
std::atomic<float> gain{1.0f};
};
SharedAudioState state;
// 主线程更新
state.gain.store(0.8f, std::memory_order_relaxed);
// 回调内读取
float currentGain = state.gain.load(std::memory_order_relaxed);
主线程更新 gain 时使用 store,回调读取时使用 load,即使两个线程同时访问也不会引入数据竞争,整个操作无锁、无阻塞。类似的思路还可以推广到环形缓冲区,用来安全地向 UI 线程传递峰值表或频谱数据。回调只负责读取和写入已经分配好的缓冲区,任何复杂的状态同步都放在回调之外完成。
四、常见问题排查:回调不触发、爆音与参数更新
如果一切配置完成后仍然没有声音,首先检查 AudioUnitInitialize 和 AudioOutputUnitStart 是否都被调用。其次确认渲染回调确实注册在输入总线的 0 号总线上,且格式设置发生在注册回调之前。部分开发者在 AudioUnitInitialize 之后才调用 AudioUnitSetProperty 注册回调,这种顺序会导致初始化快照中没有回调信息,系统进入运行状态后不会触发自定义渲染逻辑。
爆音通常与回调内耗时过长有关。一个可以快速验证的方法是减小音频设备缓冲区,或提高采样率,看问题是否加剧。如果处理逻辑本身很简单但仍然爆音,要检查是否在回调中意外调用了锁、打印或分配函数。把这些操作移出回调后,声音通常会恢复稳定。
此外,更新参数时不要从 UI 线程直接修改回调正在写入的缓冲区。建议把参数复制到原子变量或无锁队列中,让回调每次读取最新值,而不是试图在回调外操作 AudioBufferList。遵循这些实时规则后,AURenderCallbackStruct 才能真正成为一个稳定、低延迟的音频渲染入口。

