上次在做一个智能会议终端项目的时候被“多说话人分离”这个需求实实在在折磨了一通。客户提的硬性要求是不能依赖深度学习、不上GPU、还得能实时出结果。我翻了一圈发现传统麦克风阵列信号处理在这个场景下完全能打而且关键是好懂、可调试、没有黑盒。我最后收敛出来的一套流程顺手起了个名字叫ReTM算法全称是Real-time Spatial Masking即“实时空间掩蔽”。这篇文章就把整条链路从硬件选型、算法原理到代码实现讲清楚哪怕你之前只写过Python脚本、没接触过阵列信号处理也能照着搭出一版能跑的多说话人分离系统。1. 项目概述与核心思路1.1 多说话人分离为什么不一定要上深度学习多说话人分离在学术圈有个很文艺的名字叫“鸡尾酒会问题”就是几个人同时说话怎么从混合的声音里把每个人的语音单独拎出来。现在主流方案确实是以深度学习为主比如各种基于LSTM、Transformer、Conv-TasNet的模型效果确实惊艳但工程落地时你绕不开三座大山。第一座是数据。你想分离两个人同时说话就需要大量把单人干净语音和双人混合语音配对的训练数据。自己录的话标注工作量大到怀疑人生用公开数据集的话又常常跟你的麦克风阵列结构、房间声学环境不匹配迁移效果立刻打折。第二座是算力。哪怕是一个很轻量的分离模型推理时也要跑几百万次浮点运算在工控机或者PC上勉强能跑放到嵌入式设备上基本属于奢望。第三座是调试。模型训练完是一个黑盒如果它在某个方向、某个频段上分离效果突然变差你很难知道是数据问题、模型结构问题还是训练超参问题。但换一个角度想既然我们手里有5个麦克风每个说话人和阵列之间的空间位置关系是确定的那为什么不直接用空间几何信息来做分离两个人站在不同方向声音到达每个麦克风的时间差一定不同这个物理线索是天然可用的。深度学习本质上是在学一个从混合信号到干净语音的映射而阵列信号处理是在用几何关系直接构造空间滤波器后者不需要训练、不需要GPU、参数少且全部可解释。1.2 ReTM算法到底是什么它如何解决问题ReTM不是IEEE论文里的某个标准命名它是我在这类项目中沉淀出来的一套工程化流程的统称。核心思路可以拆成三步。第一步先定位。利用五通道麦克风信号通过算法实时估计当前有几个人在说话、每个人在哪个方向。这一步在阵列信号处理里叫DOA估计也就是Direction of Arrival。第二步按方向“定向收音”。只要确定了说话人方向就可以把五路信号按该方向做时间对齐后叠加这就是波束形成。目标方向来的声音会因为同相叠加而增强其他方向来的声音会因为不同相而减弱。第三步做时频掩蔽。波束形成之后还会残留干扰ReTM的做法是对每个时频点根据该点的空间响应方向出一个0到1的软掩蔽把“不像是目标方向的能量”压下去把“像目标方向的能量”保留下来。为什么叫“时空掩蔽”因为整个过程用的线索是每个时频点对应的空间方位同时又在时间轴上做平滑处理避免掩蔽值跳变导致音乐噪声。整套流程跑下来不需要任何训练数据也不需要装深度学习框架一个Python脚本加一个麦克风阵列就能落地。1.3 五麦克风比单、双麦克风强在哪如果你手头只有一个麦克风那理论上几乎无法做多说话人分离因为单通道只记录了混合信号的波形空间信息完全丢失只能靠声纹、音高之类的非空间特征去猜这恰恰是深度学习模型的领域。两个麦克风能提供一条基线方向信息但它有个先天缺陷左右对称模糊。两个人一个在阵列正前方、一个在正后方双麦克风很难分辨因为两者的到达时间差相同。五麦克风如果按均匀圆阵来摆可以做到360度无模糊定位。会议场景最常见的布局就是几个人围着一张桌子坐阵列放在桌子中间人分布在四面八方。如果用线阵或者双麦后排说话人方向估计容易翻车而五元圆阵天然就是为这种场景设计的。另外从自由度上讲5个麦克风理论上可以形成4个零陷足以同时压制多个干扰源实际工程中压两路干扰最稳。2. 硬件准备与信号采集要点2.1 五麦克风阵列的三种选型方案硬件是整套方案的地基。我试过三种路线各自适用不同阶段列个表给你对比一下。方案通道数采样率接口适合阶段ReSpeaker Mic Array v2.06麦克风接近5麦配置最高48kHz16bitUSB Audio快速原型验证XMOS多麦开发板多路I2S/TDM同步采集48kHz以上USB/以太网正式产品开发ESP32-S3 自研五麦板5路I2S TDM16kHz-48kHzUSB/串口嵌入式轻量部署我自己从快速验证到产品落地都走了一遍。最开始用ReSpeaker v2.0插入电脑就能被系统识别成标准USB声卡Windows和Linux都能直接录多轨音频很适合先把算法流程跑通。后面要做小型化设备换成了自研的五麦圆阵用一颗带TDM接口的音频编解码芯片做同步采样再通过USB把原始PCM数据传给上位机。需要注意如果你只是想学习算法ReSpeaker性价比最高如果你是在做量产设计那要仔细评估麦克风单体一致性最好选数字MEMS麦克风比如INMP441或者ICS-43434数字输出抗干扰能力强一致性也会比驻极体好一个档次。2.2 多通道同步采样与数据采集多通道时间同步是整个波束形成的命根子。如果各路信号的时间偏差超过几十微秒做时延补偿时误差会被直接放大定位和波束都会出问题。以16kHz采样率为例一个采样点的周期是62.5微秒如果你的采集链路里路与路之间相差了三五个采样点那定位角度很可能偏出十度以上。所以不要图省事用多块USB声卡去同时录音那种方案几乎没有样本级同步能力。正确做法是用支持TDM模式的音频编解码器比如ES8311、SGTL5000或者TLV320AIC3254把五路麦克风的PDM或I2S数据在硬件层合并成一条同步数据流。像ESP32-S3的部分开发板I2S外设就支持TDM模式一帧能带多通道的数据拿来直接采集五路麦克风非常合适。软件采集层面最简单的验证路径是用Python的sounddevice库它可以读取多通道输入流。伪代码如下import sounddevice as sd # 6通道都录下来取前5路作为阵列输入 duration 10 # 录音秒数 samplerate 48000 # 48kHz采样 channels 6 recording sd.rec(int(duration * samplerate), sampleratesamplerate, channelschannels, dtypefloat32) sd.wait()录完之后保存成WAV文件即可推荐用24bit格式存原始采样避免量化噪声干扰后续的时延估计。我自己在实验里还喜欢在阵列正前方约1米处拍一下巴掌用来做离线延迟校验后续如果发现各路信号起始点有偏差就能一眼看出来。2.3 阵列几何参数与空间混叠约束麦克风阵列的摆放半径不是拍脑袋定的它受空间混叠条件约束。均匀圆阵中相邻麦克风的间距d决定了阵能够无歧义处理的最大频率f_max c / (2d)其中c是声速约343m/s。五元圆阵中半径r与相邻间距的关系是d 2r · sin(36°) ≈ 1.1756r我按不同半径算了一组数据对应关系如下阵列半径 r相邻麦克风间距 d无混叠最高频率 f_max3.0 cm3.53 cm4.86 kHz4.0 cm4.70 cm3.65 kHz5.0 cm5.88 cm2.92 kHz7.0 cm8.23 cm2.08 kHz人说话的频率范围大约是80Hz到8kHz其中有大部分语音能量集中在300Hz到3kHz。所以从表格看半径3cm到4cm的阵列是最优解能保证3kHz以上频段基本不产生空间混叠。当然阵列半径越大低频指向性越好波束越窄所以如果你主要处理的是男声低频段4cm半径也会用只是8kHz附近的定位会变差。我的建议是优先无混叠选半径3.5cm到4cm再在算法里把8kHz以上频段做低通衰减反正人耳对高频分离误差的容忍度也低。3. ReTM算法原理与Python实现3.1 整体信号处理流程进入算法核心之前先建立完整流程的认知免得陷在细节里出不来。ReTM的完整信号链如下多通道采集 - 分帧加窗 - STFT变换 - DOA估计SRP-PHAT- 波束形成DAS加零陷- 时频掩蔽后处理 - ISTFT - 输出分离语音每一级输出的数据形态是采集后是5路时域波形STFT后变成5个复频谱矩阵DOA估计输出若干方向角波束形成得到一个复频谱矩阵掩蔽后还是一个复频谱矩阵最后ISTFT还原成一路时域语音。整个过程用Python的numpy和scipy就能实现不需要任何深度学习框架。工程实现上我建议把STFT参数固定下来不要每帧都重新分配内存。我常用的参数是采样率16kHz、帧长2048点128ms、帧移512点32ms、FFT点数2048、窗口函数用汉宁窗。帧长为什么取这么长因为低频信号需要足够长的窗才分得清比如100Hz的信号周期是10ms如果你用20ms的窗低频分辨率就很差但帧长太长又会牺牲时间分辨率导致说话人方向变化时反应迟钝。128ms是一个均衡值。3.2 DOA估计SRP-PHAT定位说话人方向DOA估计的经典方法有很多比如MUSIC、ESPRIT但它们在工程上需要准确估计协方差矩阵而且对麦克风幅相一致性敏感。ReTM里我推荐用SRP-PHAT即Steered Response Power with Phase Transform它本质上是把阵列“扫描”每一个候选方向计算如果声音从该方向来各路信号经过时延补偿后加起来能量有多大。能量最大的方向就是声源方向。PHAT加权的作用是去掉各通道信号的幅度影响只保留相位信息这样在混响环境下更鲁棒。SRP-PHAT的离散实现可以不用显式计算每个候选方向的“导向向量”而是直接用广义互相关累加P(θ) Σ_i Σ_j R_ij(τ_ij(θ))其中R_ij(τ)是通道i和通道j之间经过PHAT加权的广义互相关函数τ_ij(θ)是假设声源在θ方向时两个通道之间的时间差。由于我们每帧都要实时估计全遍历360度会有一定计算压力我一般分粗搜和细搜两步粗搜步长5度锁定峰值附近区域再以0.5度步长细化实时性完全够用。给一段核心代码输入是当前帧的频域数据输出是能量最高方向角import numpy as np def srp_phat_doa(X, mic_pos, theta_grid, fs, c343.0): X: 多通道频域数据shape (channels, freqs) mic_pos: 麦克风坐标shape (channels, 2) theta_grid: 候选角度数组 n_channels X.shape[0] freqs np.fft.rfftfreq(2 * (X.shape[1] - 1), 1 / fs) omega 2 * np.pi * freqs powers np.zeros(len(theta_grid)) for i, theta in enumerate(theta_grid): direction np.array([np.cos(np.deg2rad(theta)), np.sin(np.deg2rad(theta))]) tau mic_pos direction / c # 每个麦相对原点的时延 # 相位补偿后累加 Y X * np.exp(1j * omega * tau[:, None]) # (channels, freqs) powers[i] np.abs(np.sum(Y, axis0)).sum() best_idx np.argmax(powers) return theta_grid[best_idx]注意这里的omega * tau是按每个通道的绝对时延来补偿的如果两个声源同时说话谱峰上可能会有多个峰。我的做法是先找到全局最大值对应的角度作为第一说话人然后把该方向附近的响应区域清零再找下一个峰作为第二说话人。实际效果比直接聚类更简单可靠。3.3 波束形成与干扰零陷抑制DOA估计得到目标方向和干扰方向之后就可以设计空间滤波器。ReTM用一个加权向量w对5个通道做线性组合y(k,n) w^H x(k,n)其中x(k,n)是频率点k、时间帧n的5维频域向量。为了让目标方向增益为1、干扰方向增益为0我们解一个约束最小二乘问题w^H a(θ_target) 1w^H a(θ_interf1) 0w^H a(θ_interf2) 0这里a(θ)是对应方向的导向向量即各通道在该方向上的相位延迟。5个麦克风给5个自由度除了满足上述3个约束还有2个自由度可以被用于最小化背景噪声输出可以用np.linalg.pinv直接求最小范数解。从实现上看你甚至不需要每次都重新算线性方程组可以直接用“延迟求和波束 零陷投影”的方式def compute_beamformer(steer_target, steer_interf): steer_target: 目标方向导向向量 steer_interf: 干扰方向导向向量矩阵shape (n_interf, channels) n_mic steer_target.shape[0] # 初始化目标方向的常规延迟求和波束 w steer_target / n_mic # 对干扰方向施加正交投影削出零陷 if len(steer_interf) 0: A np.stack(steer_interf, axis0) # (n_interf, channels) # 将w投影到A的行空间零空间 # 先用QR分解求A^H的零空间基这一步要保证矩阵满秩 Q, R np.linalg.qr(A.conj().T, modereduced) w_zero np.eye(n_mic) - Q Q.conj().T w w_zero w return w实测下来两个干扰源距离目标方向20度以上时这种零陷波束能把干扰压掉6到10dB。如果干扰方向离目标方向太近比如小于15度零陷会吃掉目标信号本身的很多能量这时候我宁可放弃削零陷只做基本的延迟求和把分离任务交给后面的时频掩蔽。3.4 时频掩蔽后处理ReTM的核心波束形成输出之后实际上还没有彻底完成分离因为波束主瓣不够窄干扰方向仍会漏进来。ReTM的最后一个关键步骤是时频掩蔽。思路是这样的每一个时频点都可以看成是某个方向的“局部声源”贡献的。如果能估计出这个时频点的主方向就能判断它属于目标说话人还是干扰说话人。具体做法是对每个时频bin遍历候选角度找出该bin在哪个方向上“响应最强”然后把响应方向与目标方向比较。我实际使用的掩蔽计算比上一节整体DOA更精细不过为了效率并不需要对所有频点都做全角度搜索。工程实现上可以采用“相位差查表”的办法预先计算每个频点对应每个候选角度的理想相位差然后直接用各通道相位差和理想值做余弦相似度相似度越高该时频点越可能来自该方向。掩蔽值不取二值的0或1而是取软掩蔽mask(k,n) exp(-dist(θ_peak(k,n), θ_target)^2 / (2σ^2))其中dist是循环角度差σ控制软掩蔽的锐度通常取15到30度。这个公式的作用是当某个时频点的主导方向正好是目标方向掩蔽值接近1偏离目标方向越远掩蔽值越小。干扰方向即使被波束漏进来也会被这个掩蔽再压一刀。软掩蔽生成之后还要在时间轴上做平滑这一步是消除音乐噪声的关键。我常用一个长度约5帧的中值滤波from scipy.ndimage import median_filter mask_smooth median_filter(mask, size(1, 5), modenearest)中值滤波能去掉掩蔽值中孤立的异常跳点让增益变化变得平缓。最后把平滑后的掩蔽乘到波束输出上y_clean(k,n) mask_smooth(k,n) · y_beam(k,n)再经过ISTFT就能得到一路相对干净的语音。需要说明的是这里对每一个目标方向都要走一遍“波束形成 掩蔽”流程所以最终会输出两路甚至三路独立的语音文件。掩蔽要按说话人各自的DOA分开计算否则两个说话人的信号会互相穿帮。3.5 从STFT到完整分离代码骨架把上面几个模块串起来一个最小可运行的分离流程大概是这个样子import numpy as np def separate_speakers(multichannel_signal, thetas, n_fft2048, hop512, fs16000): multichannel_signal: (channels, samples) thetas: 需要分离的目标方向列表例如 [theta1, theta2] n_channels, n_samples multichannel_signal.shape # 1. STFT X [] for ch in range(n_channels): _, _, Z stft(multichannel_signal[ch], n_fft, hop) X.append(Z) X np.stack(X, axis0) # (channels, freq, frames) outputs [] for target in thetas: # 2. 波束形成干扰方向取thetas中除target之外的 interf [t for t in thetas if t ! target] # 导向向量计算省略具体代码 w compute_beamformer(steer(target), [steer(t) for t in interf]) beam_out np.einsum(c,cfn-fn, w.conj(), X) # 3. 时频掩蔽用SRP-PHAT按频点对每个时频点做方向估计 mask compute_time_frequency_mask(X, target, thetas) mask median_filter(mask, size(1, 5), modenearest) cleaned beam_out * mask # 4. ISTFT t istft(cleaned, n_fft, hop) outputs.append(t) return outputs你在真正实现时还需要把STFT封装好注意分析窗和合成窗的综合窗函数公共的做法是使用scipy.signal.stft和scipy.signal.istft或者自己用汉宁窗做重叠相加。我在开发时就踩过窗函数的坑如果分析窗和合成窗不匹配输出波形会有明显的幅值起伏听起来像喘气声。4. 仿真验证与真机调参4.1 用pyroomacoustics快速验证算法在真机上反复试错成本高变量也多。我习惯先在仿真环境里把算法跑通再上真机。这里推荐用pyroomacoustics库它可以生成任意房间尺寸、混响时间、多声源和多麦克风的语音混合信号。仿真流程很简单定义房间大小、麦克风阵列位置、声源位置、混响时间然后调用库函数生成多通道录音。我自己常用一个6m×5m×3m的房间两个说话人分别在阵列左侧60度和右侧-40度距离都是1.5米T60设置为0.3秒这样仿真数据能对齐算法预期调试起来非常顺手。用仿真数据调试的好处是你可以直接拿到真实的DOA标签和干净的分离目标直接计算SDR、STOI之类的量化指标。如果你在仿真里就已经分离得很差就不要上真机浪费时间了。只有仿真效果合格才需要考虑麦克风幅相不一致、底噪、环境混响等真机问题。4.2 麦克风一致性校准真机上最坑人的一个问题是各麦克风的幅度和相位响应不一致。即使是同一批次的MEMS麦克风幅频响应也会有±1dB左右的偏差相位偏差在高频段尤其明显。这种不一致会让“导向向量”和实际不符DOA估计出现系统偏移波束形成的零陷深度也会下降。我的校准方法是在离阵列1米左右的正前方播放一个白噪声或线性扫频信号记录5路信号然后以第一个麦克风为参考计算每一路的频域传递函数H_ch(f) X_ch(f) / X_ref(f)然后把这个传递函数的倒数做成一个频域补偿滤波器在算法预处理阶段乘到对应通道上。这个校准做一遍就行只要麦克风位置不发生机械变化结果可以长期使用。注意校准环境尽量安静混响越小越好否则校准滤波器会把房间混响也补偿进去反而弄巧成拙。4.3 关键参数表与调参经验我把ReTM这套流程里最影响效果的参数整理成一个表方便你对照排查问题。参数推荐值影响采样率16kHz起步推荐48kHz越高越利于高频定位但计算量大帧长2048点16k采样下128ms太短低频分辨率差太长响应慢帧移512点32ms决定时间分辨率和计算量DOA搜索粗步长5度决定初始峰值寻找速度DOA搜索细步长0.5度决定最终定位精度软掩蔽σ15到30度决定掩蔽锐度过小会有音乐噪声掩蔽时间平滑窗5帧中值抑制音乐噪声的关键零陷最小角度间隔15度小于该值不建议做自适应零陷调参时我的经验是“先粗后细”。先用大帧长、大平滑窗确保分离结果不炸再逐步减小平滑窗和σ提升清晰度。如果你一开始就追求“通透”的感觉把掩蔽做得太锐利极大概率会得到一堆吱吱响的音乐噪声。5. 常见问题与排查技巧实录5.1 分离输出有“音乐噪声”怎么办音乐噪声是时频掩蔽最常见的副作用它的听感是背后有一种“水声”或者“铃声”一样忽强忽弱的怪响原因是掩蔽增益在相邻时频帧之间剧烈跳变产生人为的谱线闪烁。排查和解决顺序如下先确认掩蔽值是否做了时间轴平滑没有平滑基本必出音乐噪声其次把软掩蔽的σ调大一点让掩蔽不要下探到0附近的深谷一般给一个下限比如0.1到0.3不要完全消掉噪声最后可以在STFT域加一个低强度的谱减法做噪声地板估计把残留噪声抹平一些。我给一个经验判断标准如果分离后的语音听起来“干净但发闷”说明掩蔽压得过头了适当放松σ如果“自然但背后有吱吱声”就是平滑不够加大中值滤波帧数。5.2 两个说话人方向检测经常跳变DOA跳变常见于两种场景一是帧长太短帧间噪声波动导致SRP-PHAT峰值在几个方向间乱跳二是混响严重反射声在某几帧里压过了直达声峰值点被吸到墙面方向。处理方法熟能生巧第一步把帧长从1024提到2048让每一帧的频域估计更稳定第二步对DOA序列做时间域的平滑不要直接用单帧的估计结果我做法是维护一个最近10帧的方向观测用滑动窗口取众数或者做一个简单的卡尔曼滤波第三步结合语音活动检测只在检测到语音的帧更新DOA静音帧保持原值这样能避免在无人说话时定位到随机噪声源。5.3 混响和低信噪比环境效果差混响和低信噪比是传统阵列方案的天敌。混响会让阵列收到大量来自墙壁的反射声这些反射声方向杂乱会干扰DOA估计也会让时频掩蔽产生误判。SNR低时掩蔽会把噪声误当成目标信号保留或者把目标信号误当成噪声压掉。我能给的建议比较实在第一优化设备摆放阵列尽量远离墙面至少离墙0.5米以上说话人尽量靠近阵列2米以内效果最好超过3米效果断崖式下降第二在算法前端加一个多通道维纳滤波或者简单的谱减法先把底噪压低再去估计DOA第三不要指望纯后处理能解决一切如果房间T60超过1秒建议先在物理上做吸音处理。混响时间T60与分离效果的关系我实测过一组数据参考价值如下混响时间 T60角度差 60° 的SDR提升主观听感0.3s13.5 dB很干净无明显串音0.6s8.4 dB能听懂背景微有串扰1.0s4.7 dB勉强可用需要配降噪1.5s2.1 dB基本不可用这里的SDR提升指的是目标说话人相比混合输入信干比提升的分贝数数值大代表分离更彻底。如果你的实测环境比这张表差很多通常先排查麦克风校准和DOA稳定性问题不要急着改算法。5.4 实际效果测试数据参考最后给一组我自己在办公室环境下的测试数据房间尺寸约5m×4m×2.8m混响时间约0.4s说话人距阵列1.2米两人同时朗读新闻稿采样率48kHz。两说话人角度间隔SDR提升STOI语音可懂度主观评价90度14.2 dB0.91几乎无串音60度11.8 dB0.87偶有轻微串音30度7.6 dB0.76有明显串音但可理解15度3.2 dB0.55分离失败两路混在一起从数据能看出来两个说话人角度差保持在30度以上ReTM的效果对实际会议记录场景是够用的。如果角度差只有15度任何传统的纯空间分离方法都会遇到瓶颈这时候才需要真正考虑深度学习模型来补刀。个人体会是这个方案最大的价值不是把分离效果做到极致而是让你能掌控每一个环节。项目交付时客户问“为什么分离效果不好”你能直接量化回答“因为两个人坐得太近角度差只有12度超出了阵列物理极限”。这种可控、可解释的工程方案在实际落地中比一股脑堆模型更让人安心。如果你后面想继续扩展可以在这个框架上加入回声消除、自动增益控制和声纹识别把它做成一个完整的语音前端系统。