声像(Panorama)是指声音在立体声声场中的位置感知。人耳判断声源方位的依据除了强度差和时间差之外,还有一个常被忽略的因素——相位差。当一个声道相对另一个声道存在连续变化的相位偏移时,大脑会将这种相位差解读为空间位移,从而产生声音在左右声道之间游走的错觉。本文将基于iOS平台的Core Audio框架,用全通滤波器实现声道相位旋转,并借此构建可实时调节的声像扫掠效果。

一、全通滤波器如何实现相位旋转
全通滤波器(All-Pass Filter)的幅频响应恒为1,也就是说它不会改变信号的幅度,只会改变信号的相位。这一特性让它成为做相位处理的理想工具。一阶全通滤波器的传递函数如下:
H(z) = (a + z^-1) / (1 + a * z^-1)
当参数a取不同值时,滤波器在不同频率处产生不同的相移。在某个特定频率点(比如1kHz),相位偏移量可以通过调整a来连续控制。这正好满足声像移动的需求:我们让左声道保持原始相位,右声道经过一个可变参数的全通滤波器,两个声道之间的相位差随之连续变化,听感上声像就会左右移动。
差分方程实现非常简单,直接在时域逐样本计算即可:
// 一阶全通滤波器结构体
typedef struct {
float a; // 全通系数
float prevIn; // 上一个输入样本
float prevOut; // 上一个输出样本
} AllPassFilter;
float allPassProcess(AllPassFilter *f, float in) {
float out = f->a * in + f->prevIn - f->a * f->prevOut;
f->prevIn = in;
f->prevOut = out;
return out;
}
系数a与目标相移角度的对应关系为:假设目标在频率f0处产生相移theta,可以先算出模拟角频率w0 = 2 * pi * f0 / fs的预畸变值,再通过双线性变换得到a。实际工程中更常见的做法是直接建立a与相移的查表关系,运行时用插值平滑过渡,避免系数跳变带来的爆音。
二、在Core Audio中搭建处理链路
iOS上做实时音频处理的标准路径是AudioUnit。我们需要一个Remote IO Unit获取麦克风或线路输入,也可以直接用AudioFilePlayer Unit播放音频文件,然后在渲染回调中插入自定义的相位处理逻辑。整体链路是:输入Unit -> 渲染回调(全通滤波 + 声像处理)-> 输出Unit。
先初始化AudioUnit,设置立体声输出格式。注意iOS设备内建麦克风的默认采集格式不是线性PCM双声道,需要通过AUGraph或AVAudioEngine做格式转换。下面是用AUGraph搭建的典型结构:
AudioComponentDescription ioDesc;
ioDesc.componentType = kAudioUnitType_Output;
ioDesc.componentSubType = kAudioUnitSubType_RemoteIO;
ioDesc.componentManufacturer = kAudioUnitManufacturer_Apple;
ioDesc.componentFlags = 0;
ioDesc.componentFlagsMask = 0;
AUGraphAddNode(graph, &ioDesc, &ioNode);
AUGraphOpen(graph);
AUGraphNodeInfo(graph, ioNode, &ioDesc, &remoteIOUnit);
// 设置双声道浮点输出格式
AudioStreamBasicDescription stereoFormat = {0};
stereoFormat.mSampleRate = 44100;
stereoFormat.mFormatID = kAudioFormatLinearPCM;
stereoFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved;
stereoFormat.mChannelsPerFrame = 2;
stereoFormat.mFramesPerPacket = 1;
stereoFormat.mBytesPerFrame = 4;
stereoFormat.mBytesPerPacket = 4;
stereoFormat.mBitsPerChannel = 32;
格式设置完毕后,在输出Unit上注册渲染回调。回调函数由音频渲染线程按固定周期调用,所有滤波运算都在这里完成。要特别提醒一点:渲染线程是实时线程,绝对不能在里面做内存分配、加锁或调用Objective-C方法,参数更新要通过无锁的原子变量或者环形缓冲传递。
三、渲染回调中的相位差声像实现
核心处理逻辑放在渲染回调里。思路是:输入的单声道或立体声信号先做能量归一,然后左声道直通,右声道经过全通滤波器产生相位偏移,两路叠加后输出。通过在主线程定期修改全通系数a,就能实现声像的动态扫掠。
static OSStatus renderCallback(void *inRefCon,
AudioUnitRenderActionFlags *flags,
const AudioTimeStamp *timestamp,
UInt32 busNumber,
UInt32 numFrames,
AudioBufferList *ioData) {
AudioEngine *engine = (AudioEngine *)inRefCon;
for (UInt32 frame = 0; frame < numFrames; frame++) {
float in = engine->inputBuffer[frame];
// 左声道直通,保留原始相位
float left = in;
// 右声道经过全通滤波,产生可变相移
float right = allPassProcess(&engine->apFilter, in);
// 两个缓冲区为非交错格式,分别写入
engine->leftChannel[frame] = left;
engine->rightChannel[frame] = right;
}
return noErr;
}
声像扫掠的效果由系数a的变化速度决定。快速变化时声像移动明显,类似Auto-Pan效果;缓慢变化时则产生微妙的空间开阔感。可以在主线程用一个正弦函数驱动a值:
// 每帧更新一次相位系数,实现周期性声像摆动
- (void)updatePanPosition {
static float phase = 0.0f;
phase += 0.02f;
// 将正弦值映射到全通系数区间,并用一阶平滑避免爆音
float targetA = sinf(phase) * 0.6f;
self.engine->apFilter.a += (targetA - self.engine->apFilter.a) * 0.001f;
}
这里的平滑处理非常关键。如果直接跳变系数,滤波器内部状态与新系数不匹配,输出会出现明显的咔哒声。用一阶低通的方式平滑系数过渡,可以保证声像移动连续自然。另外,扫掠速率超过一定值(大约每秒几个完整周期)后,相位差会开始被听成颤音而不是空间位移,这一点在调参时需要留意。
四、效果优化与常见问题
单级一阶全通的相移随频率变化较大,低频和高频的声像位置会不一致,听感上声像发虚。改进方法是级联多个二阶全通滤波器,让相位响应在可听频段内更加平坦,声像定位也会更稳定。二阶全通传递函数为:
H(z) = (b2 + b1 * z^-1 + z^-2) / (1 + b1 * z^-1 + b2 * z^-2)
实际测试中,级联三到四级二阶全通、覆盖100Hz到8kHz的频段,就能获得相当理想的相位旋转特性。此外还有几个工程细节值得注意:一是低频相位差效果较弱,可以结合恒定功率声像法则补充低频的强度差;二是移动端要注意滤波运算的CPU占用,定点化或NEON指令优化可以降低功耗;三是测试时务必使用耳机,扬声器的串音会掩盖相位差效果,导致听感与预期不符。
最后总结一下整体思路:全通滤波器提供不改变幅度的相位旋转能力,左右声道的相位差被人耳解读为声像位移,通过主线程动态更新滤波系数实现扫掠。这套方案不仅适用于声像移动,稍加改造还能做相位失真效果、立体声展宽以及模拟梳状滤波器等创意音效,是Core Audio实时信号处理中非常实用的一项基础技术。
Core Audio全通滤波器声像移动修改时间:2026-09-09 04:48:37