引言

写这篇博客的目的,是对双麦克风场景下的串声(cross-talk)问题进行一次系统性的记录与梳理。
本文讨论的具体场景为:两个人面对面讲话、每人各自使用一个近讲麦克风。在这种设置下,由于空间传播和指向性限制,不可避免地会出现一个麦克风同时拾取到本人与对方语音的情况,即串声问题。
在实际应用中,我们往往希望实现以下目标之一:

  • 每个麦克风只保留对应说话人的语音
  • 或者通过后处理算法,尽可能抑制对方说话人的串声,得到相对干净的单人语音轨道。

思路初探

最初遇到这个问题时,我的第一反应是:语音分离(Speech Separation)。
这里的语音分离可以进一步细分为两类问题:

  1. 人声分离(Speech Separation / Blind Source Separation, BSS)
    这类方法不依赖任何先验信息,只假设输入信号是多个声源的混合,目标是将其中的各个声源分离出来。
    在工程上,可以理解为“不关心是谁在说话,只要把不同人的声音拆开即可”。

  2. 目标人声提取(Target Speech Extraction, TSE)
    这类方法通常依赖目标说话人的先验信息(例如一段参考语音或声纹 embedding),在此基础上从混合语音中提取指定说话人的语音。
    从建模角度看,可以理解为“声纹建模 + 条件人声分离”。

初步实验与模型尝试

受限于硬件条件(仅有 CPU、无 GPU),目前主要尝试了一些计算量相对较低的模型。
在语音分离方向上,尝试了清华大学近几年提出的一篇工作:TIGER: Time-frequency Interleaved Gain Extraction and Reconstruction for Efficient Speech Separation该模型的特点是计算效率较高、结构相对轻量,并且在真实场景下的双人语音分离任务中表现较为稳定。在有限算力条件下,其分离效果总体令人满意。
在目标人声提取(TSE)方向上,也简单尝试了一些已有模型。从初步结果来看,在说话人差异较明显、参考语音质量较好的情况下,提取效果是可接受的,但尚未进行系统性的评估和对比测试。

SpeechBrain 中的语音分离模型

SpeechBrain 提供了多种可直接用于推理的预训练语音分离模型,主要基于 SepFormer 及其变体,常见模型包括:
“sepformer-wsj02mix”: “speechbrain/sepformer-wsj02mix”,
“sepformer-wsj03mix”: “speechbrain/sepformer-wsj03mix”,
“sepformer-libri3mix”: “speechbrain/sepformer-libri3mix”,
“sepformer-libri2mix”: “speechbrain/sepformer-libri2mix”,
“sepformer-wham”: “speechbrain/sepformer-wham”,
“sepformer-whamr”: “speechbrain/sepformer-whamr”,
“sepformer-whamr16k”: “speechbrain/sepformer-whamr16k”,
“resepformer-wsj02mix”: “speechbrain/resepformer-wsj02mix”,

这些模型分别针对不同的数据集、说话人数以及噪声/混响条件进行训练,实际使用时需要根据应用场景进行合理选择。

一些实验观察与经验总结

在实际测试过程中,有以下几点比较明确的经验结论:

  1. 当两个说话人的能量水平接近时(例如距离麦克风相当、说话音量接近),
    纯语音分离模型的效果通常会明显下降,残留串声较为严重。
  2. 当目标说话人与干扰说话人的能量差异较大时(例如一近一远、一大一小),
    分离效果会显著改善,这类情况也是当前分离模型最擅长的场景。
  3. 模型与场景的匹配非常重要:
    干净语音场景应优先选择在**干净数据集(如 WSJ0-2Mix)**上训练的模型;
    若输入包含明显噪声或混响,却使用“干净模型”,反而会导致分离性能下降。

总体来看,SpeechBrain 提供的 SepFormer 系列模型在标准双人分离任务中具备较强的工程可用性,但在“双人近距离、能量接近、高度重叠”的串声场景下,仍然存在明显挑战,这也是后续需要进一步结合阵列信息、说话人先验或任务约束来解决的问题。

Asteroid 中的语音分离模型

项目地址
预训练模型地址,(噪声情况下),测试了一下,这个模型和我的场景不符合,这个是从多麦信号中,增强一个说话人的语音。我得到的结果也是,一个文件中的人声清晰(又噪声),另一个几乎不可听(单纯的质量差,不是没有声音)。
convtasnet,后面换了这个模型试试,还是又残留的声音,效果一般。
WSJ0-2Mix,8 kHz,经典基准。效果也可以,也有残余的声音。

import argparse
import json
import os
from pathlib import Path
from typing import Dict, Optional

import soundfile as sf
import torch
import torchaudio
from asteroid.models import BaseModel
from asteroid.utils.hub_utils import cached_download


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="使用 Asteroid 预训练模型进行推理。")
    parser.add_argument(
        "--model-source",
        default=r"path\Asteroid\ConvTasNet_WHAM_sepclean\pytorch_model.bin",
        help="本地模型文件路径/目录或 HF repo_id。",
    )
    parser.add_argument("--input", required=True, help="输入 wav 路径。")
    parser.add_argument("--output-dir", default="outputs", help="输出目录。")
    parser.add_argument(
        "--online",
        action="store_true",
        help="允许在线拉取(默认离线模式)。",
    )
    parser.add_argument(
        "--device",
        default="cpu",
        help="推理设备,例如 cpu 或 cuda。",
    )
    return parser.parse_args()


def _resample_audio(x: torch.Tensor, orig_sr: int, target_sr: int) -> torch.Tensor:
    # 使用 torchaudio 进行重采样(更适合音频)
    return torchaudio.functional.resample(x, orig_sr, target_sr)


def _resolve_model_source(model_source: str) -> str:
    path = Path(model_source)
    if path.exists() and path.is_dir():
        # 兼容目录输入,优先查找常见权重文件名
        for name in ("model.pt", "pytorch_model.bin"):
            candidate = path / name
            if candidate.exists():
                return str(candidate)
        raise FileNotFoundError("目录中未找到 model.pt 或 pytorch_model.bin")
    return model_source


def _load_metadata(model_source: str) -> Optional[Dict]:
    path = Path(model_source)
    if path.exists() and path.is_file():
        meta_path = path.parent / "metadata.json"
        if meta_path.exists():
            return json.loads(meta_path.read_text(encoding="utf-8"))
    if path.exists() and path.is_dir():
        meta_path = path / "metadata.json"
        if meta_path.exists():
            return json.loads(meta_path.read_text(encoding="utf-8"))
    return None


def main() -> None:
    args = _parse_args()
    if not args.online:
        os.environ["HF_HUB_OFFLINE"] = "1"

    device = torch.device(args.device)
    if device.type == "cuda" and not torch.cuda.is_available():
        raise RuntimeError("请求使用 CUDA,但当前环境不可用。")

    model_source = _resolve_model_source(args.model_source)
    metadata = _load_metadata(model_source)
    model_path = cached_download(model_source)
    # 优先尝试 TorchScript,失败后再用 torch.load 加载序列化配置
    try:
        conf = torch.jit.load(model_path, map_location="cpu")
        is_script_model = True
    except Exception:
        # torch>=2.6 默认启用 weights_only,需要显式关闭以加载完整配置
        conf = torch.load(model_path, map_location="cpu", weights_only=False)
        is_script_model = False
    if is_script_model:
        model = conf.to(device)
    else:
        model = BaseModel.from_pretrained(conf).to(device)
    model.eval()

    x, sr = sf.read(args.input)
    target_sr = int(
        (metadata or {}).get("sample_rate", getattr(model, "sample_rate", 16000))
        or 16000
    )
    expected_multichannel = (metadata or {}).get("multichannel", None)
    expected_in_channels = getattr(model, "in_channels", None)
    if expected_multichannel is True or (
        expected_multichannel is None and expected_in_channels and expected_in_channels > 1
    ):
        if x.ndim == 1:
            raise ValueError("该模型需要多通道输入 wav(至少 2 通道)。")
    if expected_multichannel is False and x.ndim != 1:
        raise ValueError("该模型需要单通道输入 wav。")
    if sr != target_sr:
        # 先转为 (channels, time) 进行重采样
        x_t = torch.from_numpy(x).float()
        if x.ndim == 1:
            x_t = x_t.unsqueeze(0)
        else:
            x_t = x_t.transpose(0, 1)
        x_t = _resample_audio(x_t, orig_sr=sr, target_sr=target_sr)
        if x.ndim == 1:
            x = x_t.squeeze(0).cpu().numpy()
        else:
            x = x_t.transpose(0, 1).cpu().numpy()

    if is_script_model and x.ndim == 1:
        x_tensor = torch.from_numpy(x).float().unsqueeze(0).to(device)
    else:
        # Asteroid 常用张量格式: (batch, channels, time)
        if x.ndim == 1:
            x = x[:, None]
        x_tensor = torch.from_numpy(x).float().transpose(0, 1).unsqueeze(0).to(device)

    with torch.no_grad():
        if is_script_model:
            est = model(x_tensor)
        else:
            est = model.separate(x_tensor)

    if est.ndim == 1:
        est = est.unsqueeze(0).unsqueeze(0)
    if est.ndim == 2:
        est = est.unsqueeze(0)

    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    n_src = est.shape[1]
    for i in range(n_src):
        wav = est[0, i].detach().cpu().numpy()
        out_path = output_dir / f"spk{i + 1}.wav"
        sf.write(out_path.as_posix(), wav, target_sr)


if __name__ == "__main__":
    main()

Voice filter

论文地址.
voice-filter.
项目地址.
我跑出来的效果不好,就算是使用他仓库自带的音频,效果也不好。不是很清楚是什么情况。

Demucs 音乐源分离模型

根据其项目介绍,Demucs 是一款先进的音乐源分离模型,目前能够将鼓、贝斯和人声从其他伴奏中分离出来。
因此就没有测试了。

X-TF-GridNet

X-TF-GridNet:一种具有自适应说话人嵌入融合的时频域目标说话人提取网络
项目地址.
直接使用voice-filter给的音频来测试。
有的效果还可以,有的效果差。

Tiger

项目地址.
这个项目提出了一种显著降低参数规模和计算成本的语音分离模型:时频交错增益提取与重构网络(TIGER)
值得注意的是,TIGER 是首个参数量少于 100 万且性能接近最先进模型的语音分离模型。
其主要是对于真实世界的语音分离,对于合成的声音效果应该不会很好。
我测试了一下,使用其官方的gpu进行推理,效果还是不错的,但是使用cpu的时候明显要差一点。但是总体来说,还是比较好的。

感想

这些都是我测试过的,其实我发现效果就是speechbrain效果还不错。当然也不是说tse这些就不好,只是说可能不太适合?
后面想一想,串音吗,一个大声一个小声,能不能直接用门限方法给他去除呢。好像也是可以的。门限不仅可以降噪,还可以去除串音,这个在录制声音的时候应该也是有使用吧。就是防止录制到多个乐器。

ratio-based soft mask

Ratio-based soft mask 是一种较早提出、但在语音增强与语音分离中仍被广泛采用的时频域方法,其核心思想是在每一个时频单元上,根据不同声源的相对能量占优关系构建连续取值的掩膜函数,从而实现对目标声源的保留与对干扰声源的抑制。

场景说明

在当前场景中,存在两路音轨:(双人场景)

  • 音轨 x 1 ( t ) x_1(t) x1(t):主说话人为A,其他说话人都归为B,能量较小。
  • 音轨 x 2 ( t ) x_2(t) x2(t):主说话人为B,其他说话人都归为A,能量较小。
    两路音轨都是进场的,即各自的音轨中,具有非常明显的能量优势。
    对两个信号进行短时傅里叶变换,得到时频表示为:
    X 1 ( t , f ) = S T F T ( x 1 ( t ) ) X_1(t,f)=STFT(x_1(t)) X1(t,f)=STFT(x1(t))
    X 2 ( t , f ) = S T F T ( x 2 ( t ) ) X_2(t,f)=STFT(x_2(t)) X2(t,f)=STFT(x2(t))
    其实可以讲其中一个当作说话人,另一个当作是噪声就可以了,这样就可以回归到IAM这种掩膜方法了,便于理解。
    此外这里不对相位做额外处理。

掩膜构建

然后再每一个 ( t , f ) (t,f) (t,f)单元上构建掩膜就可以。
M 1 ( t , f ) = ∣ X 1 ( t , f ) ∣ p ∣ X 1 ( t , f ) ∣ p + ∣ X 2 ( t , f ) ∣ p + δ M_1(t,f)=\frac{|X_1(t,f)|^p}{|X_1(t,f)|^p+|X_2(t,f)|^p+\delta} M1(t,f)=X1(t,f)p+X2(t,f)p+δX1(t,f)p
M 2 ( t , f ) = ∣ X 2 ( t , f ) ∣ p ∣ X 1 ( t , f ) ∣ p + ∣ X 2 ( t , f ) ∣ p + δ M_2(t,f)=\frac{|X_2(t,f)|^p}{|X_1(t,f)|^p+|X_2(t,f)|^p+\delta} M2(t,f)=X1(t,f)p+X2(t,f)p+δX2(t,f)p
其中 p ≥ 1 p≥1 p1

  • p = 1 : p=1: p=1:较软的比例掩膜
  • p = 2 : p=2: p=2:Wiener-like 掩膜,抑制能力更强
    δ : \delta: δ:数值稳定项,防止分母为零
    在理想情况下,上述掩膜满足近似互补关系:
    M 1 ( t , f ) + M 2 ( t , f ) ≈ 1 M_1(t,f)+M_2(t,f)≈1 M1(t,f)+M2(t,f)1
    即在每个时频单元中,两路信号对能量的“占比”之和接近 1。

使用

直接讲构建好的掩膜用在对应的复数谱上即可:
在这里插入图片描述随后通过逆短时傅里叶变换(iSTFT)回到时域,得到抑制串音后的信号。

虽然,感觉声音去除了,但是感觉声音变闷了,音质有一定的影响。

总结

总体来说,针对这个场景,如果不是对音质要求特别高的话,用传统的也可以。对音质要求高的话,可以考虑用tiger然后加一个降噪应该是没问题的,当然是后处理了,需要gpu,我感觉cpu效果一般。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐