在 iOS 上做实时音频处理,增益控制通常要放在 Core Audio 的渲染回调里完成。无论是 RemoteIO 的输入回调,还是 AVAudioEngine 的自定义节点,最终拿到的都是 Linear PCM 样本。自动增益控制(AGC)、峰值限制器和 RMS 电平监测这三件事可以共用同一套信号分析结果:先计算当前缓冲区的均方根电平,再根据目标电平更新增益,最后对瞬时峰值做限制,防止削波。下面按这条链路逐步展开。

一、处理顺序与线程约束
Core Audio 的 AudioUnit 回调运行在音频渲染线程上,该线程对实时性要求极高。任何锁等待、堆内存分配、文件读写或 Objective-C 消息转发都可能造成播放卡顿甚至中断。因此,AGC、峰值限制和 RMS 计算必须放在回调内,但只能在预分配内存中处理,所有状态变量使用 C 结构体或静态变量保存。
推荐的线性处理顺序是:第一步读取 AudioBufferList 中每个通道的 mData 指针,将样本视为 Float32;第二步调用 Accelerate 计算当前帧的 RMS 和峰值包络;第三步根据 RMS 与目标电平的差异更新 AGC 增益;第四步把增益乘以样本,再过峰值限制器写入输出。如果处理的是输入回调,可以在写入应用层之前完成同样的处理,避免后续再复制。
硬件默认可能是 16 位整型或交织格式,但直接使用非交织 Float32 能减少转换步骤。可以在 AVAudioSession 中设置 preferredSampleRate 和 preferredIOBufferDuration,并在 AudioUnit 初始化时通过 ASBD 指定 kAudioFormatFlagIsFloat 与 kAudioFormatFlagIsNonInterleaved。这样每个通道的连续采样点可以直接交给 vDSP 函数处理,不需要手动解交织。
OSStatus audioCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
{
ProcessorState *state = (ProcessorState *)inRefCon;
for (UInt32 channel = 0; channel < ioData->mNumberBuffers; channel++) {
Float32 *samples = (Float32 *)ioData->mBuffers[channel].mData;
// 1. 计算RMS与峰值
Float32 rms = 0.0f;
Float32 peak = 0.0f;
vDSP_rmsqv(samples, 1, &rms, inNumberFrames);
vDSP_maxmgv(samples, 1, &peak, inNumberFrames);
// 2. 更新AGC增益
updateAGC(state, rms, inNumberFrames);
// 3. 应用增益
vDSP_vsmul(samples, 1, &state->gain, samples, 1, inNumberFrames);
// 4. 峰值限制
applyPeakLimiter(samples, inNumberFrames, state);
}
return noErr;
}
二、RMS电平监测的Accelerate实现
RMS 反映的是一段样本的平均功率,比瞬时峰值更适合作为 AGC 的控制依据。对于长度为 N 的缓冲区,RMS 的数学定义是样本平方和的均值再开方。手动循环虽然直观,但实时回调里每帧都要遍历,CPU 开销会随之上升。Accelerate 的 vDSP_rmsqv 内部使用 SIMD 指令,单次调用即可完成平方、求和、平均和开方,传入步长 1 即可处理连续样本。
如果还要同时得到峰值,可以用 vDSP_maxmgv 获取最大绝对值。峰值不等于电平,但峰值限制器需要它来捕捉瞬时冲击。实际项目中建议维护一个平滑后的 RMS 电平,而不是直接使用瞬时 RMS。因为帧长通常只有 5 到 20 毫秒,瞬时 RMS 会上下跳动,导致增益来回波动。一阶低通平滑的公式为 smoothedLevel = alpha * smoothedLevel + (1 - alpha) * instantLevel,其中 alpha 可以根据时间常数和采样频率计算。
时间常数选择会影响听感。短时间常数例如 30 毫秒,能更快跟踪语音起落;长时间常数例如 300 毫秒,电平变化更稳定但响应偏慢。一般语音场景可以取 80 到 150 毫秒。代码里可以把 alpha 预计算为 expf(-1.0f / (fs * seconds)),不要在回调里重复调用指数函数。
#include <Accelerate/Accelerate.h>
typedef struct {
float smoothedLevel;
float attackAlpha;
float releaseAlpha;
float targetLevel;
float gain;
float maxGain;
float minGain;
float peakThreshold;
float peakEnvelope;
} ProcessorState;
float computeSmoothedRMS(const float *samples, UInt32 frameCount, ProcessorState *state)
{
float instant = 0.0f;
vDSP_rmsqv(samples, 1, &instant, frameCount);
// 电平上升用较快系数,下降用较慢系数
float alpha = (instant > state->smoothedLevel) ? state->attackAlpha : state->releaseAlpha;
state->smoothedLevel = alpha * state->smoothedLevel + (1.0f - alpha) * instant;
return state->smoothedLevel;
}
三、AGC自动增益的平滑更新
AGC 的目标是让输出电平尽量靠近预设值,而不是把所有信号都放大到满幅。目标电平通常用 dBFS 表示,例如 -18 dBFS 对应线性值约为 0.125。比较当前平滑 RMS 与目标值,可以得到期望增益 desiredGain = targetLevel / max(rms, epsilon)。如果当前电平低于目标,期望增益大于 1;如果当前电平过高,期望增益小于 1。
直接把期望增益赋给增益变量会导致音量突变,甚至引入咔哒声。解决办法是使用攻击和释放两种平滑系数。当期望增益小于当前增益时,进入攻击阶段,快速降低增益;当期望增益大于当前增益时,进入释放阶段,缓慢恢复增益。攻击时间通常设 10 到 50 毫秒,释放时间设 200 到 800 毫秒,这样既能压制突发强音,又不会在静音段突然拉大底噪。
增益范围需要严格限制。过大的增益会把噪声放大得很明显,过小则可能导致声音太轻。对于语音通话,典型增益范围是 0.1 到 10.0(约 -20 dB 到 +20 dB)。在每帧处理时,先根据平滑 RMS 计算期望增益,再按当前状态选择攻击或释放系数进行一阶平滑,最后做上下限裁剪。这样得到的 gain 再交给 vDSP_vsmul 乘以样本,就可以保持音量稳定。
void updateAGC(ProcessorState *state, float rms, UInt32 frameCount)
{
float epsilon = 1e-6f;
float desired = state->targetLevel / fmaxf(rms, epsilon);
float alpha = (desired < state->gain) ? state->attackAlpha : state->releaseAlpha;
state->gain = alpha * state->gain + (1.0f - alpha) * desired;
if (state->gain > state->maxGain) state->gain = state->maxGain;
if (state->gain < state->minGain) state->gain = state->minGain;
}
四、峰值限制器的包络检测与软限幅
即使 AGC 已经控制了平均电平,仍然可能出现单个采样点或几毫秒内的瞬时高峰。数字音频中一旦样本绝对值超过 1.0,输出就会被截断,产生明显失真。峰值限制器的作用是在 AGC 输出之后做最后一道保护,对超过阈值的部分进行压缩或直接限幅。阈值一般设在线性 0.891 到 0.977 之间,对应约 -1 dBFS 到 -0.2 dBFS。
单纯的硬限幅实现简单,只要对每个样本执行 if (sample > threshold) sample = threshold; 以及负向处理,但会引入较多的奇数谐波失真,听感发硬。软拐点压缩会让过阈值部分的增益逐渐下降,比如高于阈值的部分乘以 0.3 再叠回阈值,这样可以保留一定的动态。若要更平滑,还可以使用包络检测器:当瞬时峰值超过当前包络时快速跟踪,低于包络时按释放时间缓慢衰减,再根据包络计算实时增益,避免每个样本独立决策带来的波动。
峰值限制器与 AGC 需要形成反馈。若限制器频繁工作,说明 AGC 增益过大或目标电平偏高,可以在回调中统计限制深度,并适当降低 AGC 的最大增益或目标电平。这样整套链路的声音会更自然。实现时建议在应用 AGC 增益后、写回输出前调用限制器,这样限制器处理的信号已经是增益调整后的结果,不会出现限幅后又放大导致二次削波的问题。
void applyPeakLimiter(float *samples, UInt32 frameCount, ProcessorState *state)
{
float threshold = state->peakThreshold;
float softKnee = 0.3f; // 高于阈值部分的保留比例
float envAttack = 0.001f; // 包络上升很快
float envRelease = 0.05f; // 包络下降稍慢
for (UInt32 i = 0; i < frameCount; i++) {
float absolute = fabsf(samples[i]);
if (absolute > state->peakEnvelope) {
state->peakEnvelope += envAttack * (absolute - state->peakEnvelope);
} else {
state->peakEnvelope += envRelease * (absolute - state->peakEnvelope);
}
if (state->peakEnvelope > threshold) {
float excess = state->peakEnvelope - threshold;
float scaled = threshold + excess * softKnee;
float reduction = scaled / state->peakEnvelope;
samples[i] *= reduction;
}
}
}
五、落地到RemoteIO与调优建议
把上面的状态机和函数接入 RemoteIO 时,初始化阶段需要创建一个 ProcessorState,清零并把 targetLevel、攻击释放时间、阈值等参数配置好。回调中先渲染输入数据,或者直接在输出回调中处理即将播放的缓冲区。由于回调可能被多个通道交替调用,状态最好按通道分开保存,避免双声道共享同一个增益导致声像偏移。
调优时先不要开启 AGC,只观察平滑后的 RMS 和峰值。可以通过打印或写入环形缓冲区的方式查看电平变化,确认目标电平是否匹配实际信号。之后再逐步打开 AGC,先设置较大的释放时间和较小的最大增益,听一听音量是否稳定,有没有呼吸感。若底噪被明显放大,考虑降低最大增益或加入噪声门。最后打开峰值限制器,用较强的正弦波或打击乐测试是否还有削波。
这套手动实现的方案与系统自带语音处理单元并不冲突。很多应用只需要系统 AGC 时可以选用 kAudioUnitSubType_VoiceProcessingIO,但如果需要自定义增益曲线、RMS 显示或扩展压缩器,就需要回到 Core Audio 的 PCM 回调里自己实现。掌握了 RMS 计算、增益平滑和峰值包络检测这三块,后续再加入 EQ、动态压缩或噪声抑制都会更加顺手。
Core Audio自动增益控制峰值限制修改时间:2026-10-03 19:12:37