声音虫箭
简单而言是一个“测测你的声音像JOJO系列中的谁”的网页娱乐小项目的粗浅记录。
一、项目概况
日常聊天中好友半老师分享了对声音识别趣味应用的兴趣和设想。我不知道这种应用具体是如何实现的,好奇心被激发,遂试图做个简单的小项目以稍加了解。
本项目为 JOJO 粉丝向项目,核心功能为用户录制上传音频,系统自动提取音频声纹特征,与JOJO系列角色声纹进行匹配,并进行结果输出。项目本身基于Web,尽管并没有人玩但理论上需要支持多用户并发访问。另一约束在于鄙人一毛不拔希望分文不花,真是因此绕了好大一圈啊.jpg
开发前鄙人唯一的相关经验是此古法博客。可喜可贺虽然瑕疵满满但最终还是跑了起来,于是进行简单的记录,谬误之处还望不吝指教(联系方式在主页)。
| 技术分类 | 具体技术名词 |
|---|---|
| 核心算法 | 声纹识别(说话人验证)、FBank 音频特征提取、声纹 Embedding 余弦相似度计算 |
| 模型相关 | SpeechBrain, WeSpeaker, ONNX, INT8量化, Netron |
| 前端技术 | HTML/CSS/JavaScript ( Meyda ), Web Audio API, ONNX Runtime Web |
| 后端技术 | Python ( librosa, Fbank, Numpy), Vercel Serverless Functions |
| 部署与托管 | Netlify, Cloudflare Pages, Cloudflare R2, Hugging Face Space |
| 开发工具 | Python 虚拟环境、pip 包管理、Git 版本控制、Gemini Flash(真正的开发者) |
二、过程记录
Day 1:纯 JS 实现声纹匹配的尝试,堂堂失败
一个重要的前提是,起初我以为后端必然要为服务器花钱,因此决定把困难抛给用户,纯前端实现。
直接询问哈 G 米,了解了向量匹配的原理,试图全然 vibe coding 来快速验证可行性。本着 AI 指哪我打哪的态度开始尝试如下架构:前端通过 Web Audio API 提取用户音频,用 Meyda 提取 13 维特征向量;预先生成所有角色音频的特征向量存入 JSON 文件,前端直接手写函数,完成相似度计算与结果匹配。
从 The Sounds Resource 获取少量 WAV 格式的角色语音文件作为测试例,并用 Librosa 库获取向量,存储为 JSON 文件。很快完成了能跑的程序并成功部署,然而匹配结果完全随机。怀疑特征维度不足,于是让哈G米将特征向量提升至 20 维、40 维分别尝试,匹配结果变为固定单一角色。总之程序无效,遗憾离场。
Day 2:如何一文不花地使用预训练模型,这是个问题
再问哈 G 米,得到了使用专业声纹识别预训练模型的建议。具体而言,它推荐我使用 spkrec-ecapa-voxceleb,与此同时我了解到了有趣的 Hugging Face。尽管Hugging Face提供免费接口,但空间会休眠且额度有限,因此决定依旧纯前端实现。想要在前端调用模型,似乎无法使用官网提供的 ckpt 格式达成。我试图寻找 SpeechBrain 框架下现有的 ONNX 格式预训练模型,无果,并在询问哈 G 米时饱受颠倒黑白的 AI 幻觉折磨。
Day 3:自己动手,丰衣足食?
既然如此就自己来转换吧!冗长的浪费时间之旅程就此开始了。并非完全是坏事,这一过程中习惯于图形化界面的鄙人逐渐对命令行感到亲切起来。依旧驱使哈 G 米使用 torch.onnx.export() 编写转换的 python 脚本,但转换屡屡失败,AI 与我皆缺乏修改思路。另外还缺乏的是管理计划,当时一心只想让代码跑起来,事过了无痕,令此刻试图复盘的鄙人略感为难。
查找资料时发现了一则22年的 github 帖子,帖主成功转换出了 ONNX 模型,尽管该帖讨论的是转换遇见的令人不确定的 bug。尝试按帖主的版本环境与可复现代码进行尝试,这一过程中简单了解了一下如何用 Miniconda 配置虚拟环境,并成功转换了!
Day 4:自己动手,颗粒无收!
实则并非成功。
首先,初始使用的Netlify很快到达了额度上限,于是转到 Cloudflare 进行托管。但 Cloudflare 对于单个文件大小有着25MB的限制。我转换出的ONNX模型有80+MB,遂尝试 INT8 量化压缩至24MB左右,再进行托管。部署后,程序跑了起来!但匹配结果依旧无效。
自此开始利用 Netron 查看 ONNX 的可视化结构,同时借助 AI 进行 debug。角色的 embedding 由模型而来,于是使用 python 脚本来测试角色 embedding 本身的可信度,从而验证模型的可信度。测试结果非常不妙,模型显然有问题。试了若干次微调,反复重新转换模型,未曾得到好的结果。
Day 5:Vercel 神的显灵
太弱小了,没有力量。毅然放弃自己转换,决定寻找一个更权威的 ONNX 模型。转而使用 WeSpeaker 框架下的 voxceleb_ECAPA512_LM,此模型大小非常合适,生成的 embedding 通过了可信度测试,过程之丝滑让 Day 3 和 Day 4 的我像个傻瓜。
但是,声纹匹配的结果依然不理想。此时前端的语音处理使用 JavaScript 中的 Meyda 库,本地的声音处理则使用 python 的 Librosa 库。怀疑前端语音与本地角色语音处理因库不同、算法不同而无法匹配。
理论上,python 脚本生成的 embedding 已确认有效,朝 python 脚本对齐是更好的选择。但我不想为服务器花钱,所以第一想法是利用 JavaScript 处理所有的本地文件。这一又增工作量的想法让我很是懈怠,自暴自弃地询问哈 G 米是否有免费的后端实现方法,答曰 Vercel Serverless Functions。大惊!大喜。大悲!我意识到自己之前一直在以自己预设的纯前端思路征询 AI 的意见,竟然没有直接地问过这个问题!调研方式亟待优化。
Day 6:前与后的奇迹
原先的逻辑是,一切的一切都在前端完成。而知道 Vercel Serverless Functions 的存在后,我决定利用它实现一个将音频处理为模型输入格式的接口。也即,逻辑变为,前端原始音频波形通过后端接口,处理为 Fbank 梅尔声学特征,再返回前端输入预训练模型,在前端进行模型处理与匹配。前后端结合而非完全后端,是因为模型计算量大,担心 Vercel Serverless Functions 的免费额度不足以频繁调用模型计算,因此在后端仅解决预处理对齐问题。为了更轻量而没有选择 Librosa,用 kaldi_native_fbank 进行代替。
随后 AI 指哪我打哪。也遇到了匹配度不理想的问题,尝试了归一化,最后发现是特征提取后的标准化缩放操作重复执行了 2 次……修改后结果好起来了。于是处理了其他角色的语音数据,存入 JSON 文件。
Day 7:深宅大院……
在 QQ 上发给好友,发现腾讯内置浏览器会屏蔽 Cloudflare 分布式子域名,遂紧急重定向。接着发现接口调用需要科学上网,又重定向了一下 API,转到 Vercel 的国内优化节点。当天测试可以直接使用,但后来不行了,甚至从 Cloudflare 下载模型也开始需要科学……不过暂时如此,没作更多优化了。
至此项目大概跑通,声纹匹配结果基本符合角色声线特征,无成本、支持多用户并发,可在 Web 端正常访问。
三、实际架构
前端层(Cloudflare Pages 托管)
- 音频采集模块
基于 Web Audio API 实现用户麦克风录音、本地音频文件上传功能,统一转换为 16kHz 单声道的标准 wav 格式; - 通信模块
封装 HTTP 请求,将处理后的音频文件发送至 Vercel Serverless 后端接口,接收返回的标准化特征数据; - 模型推理模块
基于 ONNX Runtime Web 加载 WeSpeaker 框架 ONNX 模型,接收后端返回的特征数据,生成用户音频的声纹 embedding; - 相似度计算与展示模块
计算用户 embedding 与预存角色 embedding 的余弦相似度,完成排序后,向前端用户展示 Top-N 匹配的角色与对应相似度数值。
后端层(Vercel Serverless Functions 托管)
- 接口服务模块
封装为标准 HTTP 接口,处理前端的跨域请求,完成音频文件的接收与结果返回; - 音频预处理模块
基于 kaldi_native_fbank,对接收的 wav 文件完成重采样、降噪、幅值归一化、时长裁剪等标准化处理,保证与角色 embedding 预生成的处理逻辑完全一致; - 特征提取模块
基于 Fbank 特征提取库,生成符合 WeSpeaker 模型输入要求的特征张量,完成标准化处理后返回给前端。
数据层
-
角色声纹库
预先生成的所有角色音频的声纹 embedding,存储为 JSON 格式文件,随前端静态资源一同部署在 Cloudflare Pages,前端可直接读取; -
模型文件
WeSpeaker 框架 ONNX 模型文件,部署在 Cloudflare Pages,前端加载后完成本地推理。
四、核心功能
声纹特征处理
- 音频标准化
遵循预训练模型的标准化流程,将输入音频统一转换为 16kHz 采样率、单声道、16bit 深度的 wav 格式,完成幅值归一化,消除音量差异对特征提取的影响; - FBank 特征提取
遵循预训练模型的标准化流程,对预处理后的音频提取梅尔滤波器组(FBank)特征,作为模型的输入数据; - 声纹 Embedding 生成
将 FBank 特征输入 WeSpeaker 预训练模型,生成固定维度的声纹 embedding 向量,该向量可唯一表征说话人的声纹特征; - 相似度匹配
通过余弦相似度算法,计算用户声纹 embedding 与所有角色 embedding 的相似度,数值越接近 1,代表声纹匹配度越高,最终按相似度从高到低排序输出结果。
无服务器部署
- 前端部署
前端静态资源打包后,推送至 Cloudflare Pages,自动完成构建与全球 CDN 分发,保证访问速度; - 后端部署
后端 Python 逻辑封装为 Vercel Serverless Functions,无需提前配置服务器,仅在用户发起请求时触发运行,免费额度覆盖项目需求; - 重定向配置
通过自定义域名绑定与重定向配置,解决国内访问封禁与延迟问题。
五、问题整理
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 纯前端实现的匹配结果完全随机,提升维度后固定匹配单一角色,无任何区分度 | 前端 Meyda 音频库与 Python 端 librosa 的特征提取算法实现不一致,导致前端生成的 embedding 与预存的角色 embedding 分布完全不匹配,相似度计算失效 | 重构为前后端混合架构,将音频预处理与特征提取统一放在后端 Python 环境实现,保证预生成与推理阶段的特征逻辑一致 |
| 可用的SpeechBrain 模型转换 ONNX 失败 | SpeechBrain 原生不支持 ONNX 导出,模型源码中的动态长度处理逻辑与 ONNX 静态图机制不兼容,旧版本模型存在导出 bug | 更换为原生支持 ONNX 导出、有成熟官方量化方案的 WeSpeaker 预训练模型,规避手动转换 |
| 模型推理后所有相似度数值均接近 0,无法区分不同音频 | 特征提取后的标准化缩放操作重复,导致特征分布被过度压缩,不同声纹的特征差异完全消失 | 逐行排查特征处理全流程,移除重复的缩放逻辑 |
| 免费托管平台额度耗尽;默认域名被国内平台封禁,国内访问不稳定 | Netlify 免费额度上限低,Cloudflare Pages、Vercel 的默认子域名被大量滥用,被微信 / QQ 内置浏览器屏蔽,境外节点国内访问延迟高 | 前端迁移至 Cloudflare Pages,后端使用 Vercel Serverless,绑定自定义子域名,配置重定向至 Vercel 国内优化节点 |
| 原生模型文件过大 | 原生浮点型预训练模型参数体积大 | 采用 INT8 模型量化技术 |
六、预处理代码
import numpy as np
import soundfile as sf
import kaldi_native_fbank as knf
class WeSpeakerLightPreprocessor:
def __init__(self, num_mel_bins=80, frame_length=25, frame_shift=10, dither=1.0):
# 1. 显式创建并配置 opts,确保它在 __init__ 作用域内可用
opts = knf.FbankOptions()
opts.frame_opts.dither = dither
opts.frame_opts.frame_length_ms = frame_length
opts.frame_opts.frame_shift_ms = frame_shift
opts.frame_opts.samp_freq = 16000
opts.frame_opts.window_type = "hamming"
opts.mel_opts.num_bins = num_mel_bins
# 将 opts 保存为实例属性,供 __call__ 每次重置时使用
self.opts = opts
self.target_sr = 16000
# 2. 初始实例化特征提取器
self.fbank_fn = knf.OnlineFbank(self.opts)
def _resample(self, audio, orig_sr):
"""使用 numpy 线性插值实现重采样"""
duration = len(audio) / orig_sr
num_samples = int(duration * self.target_sr)
orig_indices = np.arange(len(audio))
target_indices = np.linspace(0, len(audio) - 1, num_samples)
return np.interp(target_indices, orig_indices, audio).astype(np.float32)
def __call__(self, wav_path):
# 1. 读取音频
audio, sample_rate = sf.read(wav_path)
# 2. 声道合并 (Mono)
if len(audio.shape) > 1:
audio = np.mean(audio, axis=1)
# 3. 动态重采样至 16000Hz
if sample_rate != self.target_sr:
audio = self._resample(audio, sample_rate)
# 4. 模拟 Kaldi 的幅度缩放 ([-1, 1] -> [-32768, 32767])
# 根据文档,Kaldi 处理的是 PCMS16 范围的数据
audio = audio * 32768.0
# 5. 核心:重置提取器状态
# 每次处理新文件时,必须使用 self.opts 重新创建实例,
# 否则 OnlineFbank 会保留上一个音频文件的末尾缓存
self.fbank_fn = knf.OnlineFbank(self.opts)
# 将数据馈入提取器
self.fbank_fn.accept_waveform(self.target_sr, audio.tolist())
# 6. 提取所有已就绪的帧
frames = []
for i in range(self.fbank_fn.num_frames_ready):
frames.append(self.fbank_fn.get_frame(i))
if not frames:
raise ValueError(f"音频文件 {wav_path} 太短,无法提取特征。")
fbank = np.stack(frames).astype(np.float32)
# 7. CMVN (均值归一化)
fbank = fbank - np.mean(fbank, axis=0)
# 8. 整理形状 [1, T, 80] 符合 ONNX 输入要求
return np.expand_dims(fbank, axis=0)
def get_onnx_input_light(wav_path):
preprocessor = WeSpeakerLightPreprocessor()
return preprocessor(wav_path)
# 调用演示
if __name__ == "__main__":
wav_file = "test.wav"
input_data = get_onnx_input_light(wav_file)
print(f"ONNX 输入准备就绪,形状: {input_data.shape}, 类型: {input_data.dtype}")
七、相关链接
JOJO | 声音虫箭
The Sounds Resource
Netron
Speechbrain | spkrec-ecapa-voxceleb
Wespeaker | wespeaker-ecapa-tdnn512-LM
代码先锋网 | kaldi 中 fbank 特征提取详解
开发:2026.02.05 — 2026.02.11
记录:2026.03.05