全通滤波器在幅频响应上保持平坦,却会按频率引入相位偏移,这为立体声空间处理提供了不改变音色的前提。与普通均衡器不同,全通滤波器的零点和极点关于单位圆互为倒数,它只改变信号各频率分量的相对时延。Core Audio在iOS端提供了音频单元、实时渲染回调和环形缓冲等基础能力,开发者可以在此基础上搭建相位旋转和声像调制逻辑。本文将相位旋转视为对单声道样本的时变全通滤波,将声像颤音视为左右声道相位差在低频振荡器驱动下的动态变化,并延伸到中侧编码的立体声宽度扩展。

全通滤波器相位旋转模块的实现
一阶全通滤波器的传递函数可以写成 H(z) = (a + z^-1) / (1 + a z^-1),其中系数 a 控制相位旋转的强度。该滤波器对所有频率幅频响应恒为 1,但相位会从 0 逐渐变化到 -π,变化中心频率由 a 决定。将这样的滤波器串接在左右声道上,如果两侧系数不同,就会产生随频率变化的相位差。这个相位差会让不同频段到达双耳的时间不一致,人耳就会判断声源位置发生变化。实际代码中常用差分方程 y[n] = a x[n] + x[n-1] - a y[n-1] 来处理,每个声道维护自己的历史样本。
Core Audio 的实时渲染线程对耗时操作非常敏感,因此全通滤波器不应该使用动态内存分配或锁。我们可以定义一个结构体保存系数、上一帧输入和上一帧输出。每次处理一个采样点时,先计算当前输出,再更新历史状态。系数 a 的值需要限定在 -1 到 1 之间,否则滤波器会不稳定。对于 44.1kHz 采样率,a 在 0.1 到 0.7 范围内变化时已经能产生明显的空间错位感。下面是一段 C++ 处理代码,可以直接放入 AUv3 效果器的 DSP 内核。
struct FirstOrderAllpass {
float a = 0.0f;
float x1 = 0.0f;
float y1 = 0.0f;
float process(float x) {
float y = a * x + x1 - a * y1;
x1 = x;
y1 = y;
return y;
}
void reset() {
x1 = 0.0f;
y1 = 0.0f;
}
};
// 左右声道各保留一个全通滤波器
FirstOrderAllpass apL;
FirstOrderAllpass apR;
在 render 回调中,左右声道分别调用 process 即可。如果要让相位旋转更明显,可以将两个一阶全通串联成二阶甚至更高阶结构。但阶数越高,相位旋转越大,也要防止过度改变瞬态。尤其是打击乐和语音信号,相位偏移过大会造成声音软化,失去冲击力。因此声像颤音调制通常会限制相位旋转的调制深度,而不是把系数拉到极端值。
相位差驱动的声像颤音调制
声像颤音不是简单的左右音量交替,而是通过动态改变左右声道的相对相位,让声像在空间中周期性移动。最直接的实现方式是保持左声道全通滤波器的系数固定,右声道系数随时间在某个中心值附近摆动。这样左右声道的相位差会周期变化,听感上声音会忽左忽右,但不会产生传统音量颤音那种响度起伏。调制信号可以用正弦波 LFO,频率通常在 0.1Hz 到 8Hz 之间。低于 0.1Hz 时移动过慢,高于 8Hz 时容易产生类似合唱或旋转扬声器的效果。
为了在实时环境中平滑调制参数,不能每个采样点都重新计算系数 a,否则可能产生噪声。可以通过一次一阶平滑,让目标系数逐步逼近。代码中维护当前系数 currentA 和目标系数 targetA,在每个采样周期计算差值并乘以平滑因子。平滑因子与采样率和时间常数有关,例如 timeConstant 为 20ms 时,smooth = 1 - exp(-1 / (sampleRate * 0.02f))。这样 LFO 产生的目标值被平滑后,全通滤波器系数不会突变。
下面是将 LFO 与左右声道相位差结合的处理代码。左侧全通系数设为固定值 0.35,右侧全通系数围绕 0.15 变化,幅度为 0.25,这样相位差会在正负之间摆动。lfoPhase 累加频率,lfoValue 使用正弦函数计算。为了减少 CPU 占用,也可以使用波表或多项式近似正弦。
struct StereoPhaserModulator {
FirstOrderAllpass apL;
FirstOrderAllpass apR;
float lfoPhase = 0.0f;
float lfoRate = 1.2f; // Hz
float lfoDepth = 0.25f;
float centerA = 0.15f;
float fixedA = 0.35f;
float currentA = 0.15f;
float smoothFactor = 0.002f;
void prepare(float sampleRate) {
apL.reset();
apR.reset();
smoothFactor = 1.0f - expf(-1.0f / (sampleRate * 0.02f));
apL.a = fixedA;
}
void processBlock(float *left, float *right, int numFrames) {
for (int i = 0; i < numFrames; ++i) {
// 计算 LFO
lfoPhase += lfoRate / 44100.0f;
if (lfoPhase >= 1.0f) lfoPhase -= 1.0f;
float lfo = sinf(2.0f * M_PI * lfoPhase);
float targetA = centerA + lfo * lfoDepth;
if (targetA > 0.7f) targetA = 0.7f;
if (targetA < -0.7f) targetA = -0.7f;
currentA += (targetA - currentA) * smoothFactor;
apR.a = currentA;
float inL = left[i];
float inR = right[i];
left[i] = apL.process(inL);
right[i] = apR.process(inR);
}
}
};
这里为了示例把采样率固定为 44100,实际项目中应从 audio unit 的格式中读取。正弦函数在实时线程中调用一般可以接受,但如果要处理几十个通道,建议改成查表或近似计算。另外,相位调制深度不宜超过 0.7,因为系数接近 1 时低频相位旋转会非常剧烈,可能导致单声道混合时出现明显的梳状滤波。
声像颤音的听感还会受到输入信号频率分布的影响。全通滤波器对高频和低频的相位旋转不同,人耳对中频区域的空间信息更敏感。因此当输入是吉他、人声等中频丰富的素材时,这种调制效果会更突出。若输入是低频贝斯,几乎感觉不到声像移动,因为低频相位差主要由传播时间差决定,全通滤波器在低频产生的相位变化相对平缓。这是设计效果器时需要考虑的频段差异。
立体声宽度扩展与中侧处理
实现宽广声场的另一个常用手段是增加侧信号能量。中侧编码把立体声信号拆成 M = (L + R) / 2 和 S = (L - R) / 2 两部分,M 是左右声道共同信息,S 是差异信息。将 S 乘以一个大于 1 的增益再重新合成,左右声道的差异就会被放大,声音变得更宽。但简单增加 S 会使单声道播放时左右声道求和后差异信息部分互相抵消,导致某些频段丢失。为了缓解这个问题,可以在处理链中加入全通滤波器,给侧信号增加额外的相位旋转,使单声道混合时不会出现过于明显的梳状抵消。
另一种更自然的宽度扩展是使用左右声道相位差。将右声道信号经过一个全通滤波器,且该滤波器的相位响应在中高频产生接近 90 度的偏移,再与左声道叠加,会模拟出声音到达两耳的时间差和相位差。这种方法不直接放大侧信号,因此对单声道兼容性的影响相对较小。实际实现时可以在左声道原样输出的同时,右声道混合一部分来自左声道的全通输出,或者左右互馈。互馈量控制宽度,互馈量过大会产生空间模糊感。
下面是一个基于中侧处理的宽度扩展代码片段。它先计算 M 和 S,对 S 应用一个固定的全通滤波器来旋转相位,再乘以宽度增益,最后合成左右声道。宽度增益 width 的范围可以设置为 0.0 到 2.0,1.0 表示原始宽度。
struct WidthExpander {
FirstOrderAllpass sideAllpass;
float width = 1.4f;
void prepare(float sampleRate) {
sideAllpass.reset();
sideAllpass.a = 0.5f;
}
void processBlock(float *left, float *right, int numFrames) {
for (int i = 0; i < numFrames; ++i) {
float l = left[i];
float r = right[i];
float mid = (l + r) * 0.5f;
float side = (l - r) * 0.5f;
// 对侧信号做全通相位旋转
float sideProcessed = sideAllpass.process(side);
// 扩展宽度:增加处理后的侧信号能量
sideProcessed = sideProcessed * width;
left[i] = mid + sideProcessed;
right[i] = mid - sideProcessed;
}
}
};
中侧处理还可以和前面的声像颤音调制串接。先进行宽度扩展,让声场变宽,再通过时变相位差让声像在宽声场中移动,效果会更加明显。串接顺序会影响最终听感:如果先做声像调制再扩展宽度,侧信号的相位变化会被进一步放大,空间感更夸张;反过来则移动轨迹会更集中。实现时可以在效果器界面上暴露两个模块的顺序开关,或者让用户调节 mix 比例。
需要特别强调单声道兼容性检查。在 iOS 设备上,很多外放和蓝牙音箱可能将立体声折叠为单声道播放。宽度扩展后的音频如果单声道混合后出现严重频率抵消,会让用户觉得声音单薄。可以在开发过程中加入一个单声道试听开关,将左右声道求和衰减后输出到两个声道,模拟单声道效果。通过调整侧信号增益和全通滤波器系数,尽量在宽广度和兼容性之间找到平衡点。实际产品中 width 增益建议控制在 1.0 到 1.6 之间,超过 1.6 后单声道兼容通常明显下降。
参数平滑与实时安全
Core Audio 实时渲染线程要求所有处理都不能阻塞,不能申请内存,也不能访问可能导致优先级反转的锁。全通滤波器和宽度扩展虽然计算简单,但用户界面传下来的参数不能直接赋值给当前系数。UI 线程写入的参数变化可能出现在任意采样位置,直接改系数会造成波形不连续。解决方法是使用无锁的原子变量或环形缓冲,在 render 回调中读取最新参数,再通过一阶平滑逐步应用。上面代码中的 currentA 平滑就承担了这种角色。
如果参数变化频繁,例如声像颤音的速率或深度由用户拖动滑杆控制,目标参数可能每个回调都不同。此时可以使用参数队列,UI 线程将参数放入队列,渲染线程在每次 processBlock 开始时取出并更新目标值。对于不会频繁变化的参数,如宽度增益,可以直接使用原子变量。不管哪种方式,都要保证在 processBlock 中不进行 malloc、free、new、delete 等操作。内存缓冲区应该在 prepare 或 allocateRenderResources 阶段分配好。
最后,测试音频效果时建议使用包含瞬态和持续声音的素材,例如鼓组、钢琴、人声和粉红噪声。观察波形在调制过程中是否出现削波。立体声宽度扩展如果侧信号增益过大,某些采样点可能超过 -1.0 到 1.0 范围。可以在输出前增加软限幅器,也可以在宽度扩展前降低输入增益,为扩展预留余量。更好的方式是使用浮点格式在内部处理,输出到硬件前再由系统处理限幅。
Core Audio全通滤波器立体声扩展修改时间:2026-09-18 09:02:31