Unity使用VoxLock实现语音隔离

把 VoxLock 移植到 Unity:C# 逐样本对齐 Python 参考实现

VoxLock 是今年开源的语音隔离模型,27M 参数,针对语音助手和电话通话。输入只要一段通话音频,模型自己判断谁是主叫,把房间里其他人的说话声去掉,不需要事先指定保留谁。

做数字人、远程会议、展厅语音交互时,经常遇到外放的声音被麦克风又收回去,或者环境里有人插话。VoxLock 只有两张 ONNX 图,算法延迟 24 ms,CPU 上能跑实时,放进 Unity 正合适。但官方只有 Python 实现,所以我自己移植了一版 C#。

我给自己定的标准比"能跑"高:输出要和 Python 参考实现逐样本对上。最后的结果是,fp32 模型 max |diff| 1.5e-7,相关系数 1.000000;int8 模型跑真实素材,相关系数同样是 1.000000,最大差值 3.1e-5,正好是 16 位 PCM 的一个量化台阶。

先把模型的管线读清楚

动手之前得先读透管线,不然很容易出现"跑起来了但结果不对"的情况。VoxLock 结构不复杂,但有几处必须精确对齐。

两张图

复制代码
enroll.onnx   in:  enroll (1, T)                       out: enroll_n, k, v
chunk.onnx    in:  wav (1, S), enroll_n, k, v, h, c     out: frames (1, K, 512), h_out, c_out

enroll 只在 3 秒的声音样本上跑一次,输出说话人条件向量(enroll_n、k、v,后两个是 attention 的 key/value)。chunk 在通话的每一帧上跑,带着 LSTM 状态 h、c。

跨帧传递的只有 LSTM 状态,没有别的状态,也没有依赖历史长度的归一化。所以流式处理和整文件处理的结果完全相同,两种方式下模型每一帧看到的输入一样。移植时只要流式路径写对,整文件路径就不会有问题。

常量不能改

csharp 复制代码
public const int SampleRate = 16000;
public const int Hop = 128;        // 8 ms 跳长
public const int FftSize = 512;    // 32 ms 窗
public const int Pad = 256;        // 反射填充
public const float Gain = -0.5f;   // 输出电平还原

Gain 是负数,我第一次看到以为写错了。模型用尺度不变损失训练,输出的说话人信号大约被放大到 -2 倍,波形形状正确,电平和输入对不上。乘 -0.5 既还原了幅度,也翻转了相位,所以负号是必须的。

客户端要做的四件事

模型只负责"给一段波形,输出分离后的帧",其余都在客户端:

  1. 输入两端各做 256 个样本的反射填充(对应 torch.stft 的 center=True)。
  2. 每次在 (k-1)*128 + 512 个样本上跑 chunk 图,并带上 LSTM 状态。
  3. 用 Hann² 窗把 512 点的帧重叠相加,再除以累加窗做归一化。
  4. 丢掉开头 256 个填充样本,乘上 GAIN。

第 3 步最容易漏掉除法。分析窗和合成窗都是 Hann,相乘得到 Hann²,重叠相加后必须除以 Hann² 的累加和才能还原,不除的话输出会有周期性起伏。

移植中踩的坑

核心代码只有 7 个文件,两千多行,一半是注释。费时间的是那些细节。

ONNX 输入必须是 rank-2

最先遇到的一个。图要的是 (1, T),不是 (T,):

csharp 复制代码
var t = new DenseTensor<float>(new[] { 1, sample.Length });
sample.AsSpan().CopyTo(t.Buffer.Span);

传一维数组会直接报 Invalid rank for input。Python 里 sample[None] 就解决了,C# 里要自己构造形状。

输出缓冲要比输入大

重叠相加会压住一个窗的数据,追赶积压时,一次输出的样本数可能比输入多 512 个。所以:

csharp 复制代码
public static int OutputCapacityFor(int count) => count + VoxLockConfig.FftSize;

按 count 分配缓冲的话,某些节奏下会写越界。现在这个 API 是强制检查的,缓冲不够会直接抛异常,不会悄悄出错。

用环形缓冲替换不断增长的数组

参考实现的 _run 每次都这样写:

python 复制代码
ola = np.concatenate([self.ola, np.zeros(k * HOP, np.float32)])
env = np.concatenate([self.env, np.zeros(k * HOP, np.float32)])
...
self.ola, self.env = ola[done:], env[done:]

每处理一批帧就重新分配整个尾部数组再切片。逻辑清楚,但通话一长就是平方复杂度,跑一小时的通话会慢到不能用。

我改成了固定容量的环形缓冲,容量取 FftSize + framesPerRun * Hop,这样活跃窗口和正在输出的样本不会重叠,取模也不会有歧义。已经输出的位置会清零,下一次累加才能保持正确,同时不用清其他位置。

这样耗时是线性的。这是整个移植里唯一一处有意偏离参考实现的结构,但算术上等价,max |diff| 1.5e-7 就是证据。

缓冲扩容要算上起点偏移

这个 bug 比较隐蔽。缓冲区从 _bufStart 开始用,要检查的是:

csharp 复制代码
int needed = _bufStart + _bufLen + extra;   // 不是 _bufLen + extra

只判断 _bufLen + extra 的话,起点前移之后就会写到末尾之外,Array.Copy 要么抛异常,要么静默截断。

WAV 读取器丢数据

流式处理文件时,磁盘一次读 4096 帧,调用方一次只要 320 个样本。最早的实现是这样:

csharp 复制代码
int n = _stream.Read(buffer);
Array.Copy(buffer, 0, destination, 0, Math.Min(n, destination.Length));  // 其余丢了

4096 帧只用了 320 个,剩下 3776 个直接丢了。代码看着没问题,直到发现输出音频在跳帧。

修法是加一个待取队列:磁盘读一块,重采样后先存着,调用方要多少给多少。修完后我用 64/128/320/1000/4096/20000 六种块大小读同一个文件,结果完全一致(max |diff| = 0)。

重采样也必须有状态。小数读取位置和上一个采样点要跨块保留,否则每个块边界都会出现断点。线性插值很简单,但跨块保持状态很容易忘。

int8 模型不是 bit-exact

同一段音频,每次调用处理 100 帧和处理 2 帧,int8 的输出差 4.7e-3。一开始我以为是移植错了,后来用同一份管线代码、只改 framesPerRun,复现了一样的差异,才确定是模型本身的特性。

动态量化的图会根据本次调用里各帧的取值范围推算激活 scale,所以一次送多少帧会改变计算过程。fp32 和 fp16 的差异在 3e-7 左右,int8 约 5e-3。

这个差异听不出来,而 int8 快得多,实际部署还是用 int8。要做精确的数值对比就用 fp32。

结果

精度与性能

本机实测,Unity 2021.3,8 秒音频,2 线程:

精度 加载 RTF(整文件) RTF(流式文件) 流式 vs 离线
Int8 0.28 s 0.37 0.86 4.7e-3(量化)
Int8Full 0.20 s 0.38 0.85 9.1e-3(量化)
Fp16 0.27 s 0.43 1.16 3.3e-7
Fp32 0.25 s 0.42 1.18 3.0e-7

RTF 是处理每秒音频花的计算秒数,小于 1 就能跟上实时。int8 整文件的 RTF 是 0.37,也就是处理 1 秒音频用 0.37 秒。

流式文件路径的 RTF 大约是整文件的两倍,因为文件要读两遍:第一遍只算能量,找出最响的 3 秒当声音样本,第二遍才真正跑模型。好处是内存恒定,处理一小时的录音,峰值内存也只有几十 KB。

和 Python 参考实现对比

fp32 模型,8 秒合成通话:

复制代码
max |diff|  1.490e-7
mean |diff| 1.07e-8
correlation 1.000000

真实素材用官方 demo 的两人对话,14 秒,int8 模型:

复制代码
输入                : -25.45 dB
Unity 实际输出      : -27.59 dB   corr(输入, 输出) = 0.7579
Python 参考输出     : -27.59 dB   corr(输入, 输出) = 0.7579

Unity 输出 vs 参考  : corr = 1.000000   max |diff| = 0.000031

0.000031 就是 1/32768,也就是 16 位 PCM 的一个量化台阶,两边输出在 16 位精度下完全一样。

一次排查:输出和输入一模一样

移植完成后,我拿一段 40 秒的真人录音测试,输出听起来和输入几乎没区别,一点隔离效果都没有。我第一反应是移植有问题。

查了一阵才发现,这段音频是从官方 demo 的 talker.mp3 转出来的。talker.mp3 在官方网页 demo 里是用来混入第二个说话人的,本身就是一段干净的单人录音。把它当输入,里面没有第二个人声可去掉,输出和输入一样反而是对的。

后来我在 demo 里加了一个 Similarity 字段,报告输入和输出的相关系数:

csharp 复制代码
Similarity = MeasureSimilarity(inputPath, outputPath);

string note = Similarity > 0.98f
    ? "  (output ~= input: nothing to remove, use a file with two voices)"
    : "";

判断很简单:0.76 左右,说明第二个人声被分离掉了;0.98 以上,说明输出基本就是输入的副本,没有东西可去。这比靠耳朵判断可靠,听感很容易被预期带偏。

代码结构

复制代码
Assets/Scripts/VoxLock/
├── VoxLockConfig.cs       常量、精度枚举、窗函数
├── VoxLockSession.cs      两个 ONNX 会话、张量管道、LSTM 状态
├── VoxLockStream.cs       锁定到某个声音的流式隔离(环形缓冲重叠相加)
├── VoxLockLiveStream.cs   自动找说话人的实时流
├── VoxLockAudio.cs        对外入口:Isolate / CreateStream / IsolateFile / ...
├── VoxLockWav.cs          WAV 读写(流式 reader + writer)
└── VoxLockModelLoader.cs  StreamingAssets 加载

对外 API 我特意做成和 Python 包一一对应,从 Python 过来基本不用换思路:

csharp 复制代码
using VoxLock;

var session = VoxLockSession.Create(
    VoxLockModelLoader.LoadBytes("enroll.onnx"),
    VoxLockModelLoader.LoadBytes("chunk_int8.onnx"),
    VoxLockPrecision.Int8, threads: 2);

// 整通电话进,主叫出,长度不变
float[] caller = VoxLockAudio.Isolate(session, call);

// 流式:从第一块就开始隔离,比输入晚 24 ms
using (var stream = VoxLockAudio.CreateStream(session, sample))
{
    var output = new float[VoxLockStream.OutputCapacityFor(chunk.Length)];
    int n = stream.Push(chunk, chunk.Length, output);
    int tail = stream.Flush(flushBuffer);
}

// 或者让它自己找主叫:听够 3 秒语音之前原样透传
using (var live = VoxLockAudio.CreateLive(session, seconds: 3f))
{
    int n = live.Push(chunk, chunk.Length, output);
}

CreateLive 的做法是:先原样透传,同时按 10 ms 一帧统计能量。噪声底取最近 2 秒里最安静的那一帧(用窗口最小值,这样连续不停顿地说话也能凑够样本)。听够 3 秒语音后,把这段语音当声音样本,从这一刻开始隔离。

切换时有两处处理:一是先让流在最近 250 ms 的音频上跑一遍,输出丢掉,这样它不是从空的 LSTM 状态起步;二是隔离后的音频在 10 ms 内淡入,避免声音突变。

示例音频处理效果图

最后

VoxLock 是 Apache-2.0,这个移植版沿用同一许可。这点对政企交付的项目很重要,GPL 系的方案在这类场景里通常直接被排除,这也是我选 VoxLock 而不是其他开源隔离方案的原因之一。

项目地址 https://github.com/xue-fei/voxlock-unity

模型文件在 Hugging Face,onnxruntime 的 Unity 封装用的是 onnxruntime-unity 和它的 CPU 插件 onnxruntime-unity-cpu。


相关推荐
Jmyd01234 小时前
博物馆数字展厅系统架构设计:实景采集到云渲染全链路
unity·系统架构·博物馆·数字展厅·博物馆数字化
解决小子5 小时前
【虚幻引擎】【GAS】模块详解
游戏引擎·虚幻·gas
一只大鸽子5 小时前
游戏开发笔记0-Godot启动
笔记·游戏引擎·godot
weixin_424294678 小时前
对于未加入 Script Execution Order 的脚本
unity
暴走约伯8 小时前
【VFX工具链源码拆解 03】告别机械劳动(下):材质字典映射批处理与智能纹理槽识别
unity·游戏引擎·材质
Wanda12148 小时前
机器人与机电一体化3D数字孪生机器-Day9
人工智能·3d·unity·机器人·数字孪生
暴走约伯9 小时前
【VFX工具链源码拆解 01】总体架构:大型项目特效流程的程序化落地
开发语言·经验分享·3d·unity·架构·游戏引擎
Oiiouui1 天前
Godot-GridGame架构DI容器全局联动设计
架构·游戏引擎·godot
平行云1 天前
建筑BIM/3D可视化轻量级实时评审与分享方案
unity·ue5·3dsmax·实时云渲染·云桌面·云游戏·建筑bim