声音虫箭


简单而言是一个“测测你的声音像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