鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话
在提词器项目中,产品经理提出了一个需求:让提词器仅通过声纹识别判断是否为本人朗读,只有本人才触发翻页。本质上这是一个说话人验证(Speaker Verification)的二分类问题——用户预先录入几段声音作为模板,之后每次朗读时提取语音特征并与模板比较,相似度超过阈值则翻页。最终在鸿蒙设备上,我们基于C++ NAPI和ONNX Runtime实现了端侧声纹识别方案,验证速度在100~200ms之间,且无需联网。整个方案分为三层:
- C++ NAPI层:处理核心计算——音频信号预处理、FBank特征提取、ONNX模型推理、余弦相似度计算。
- ArkTS服务层:负责胶水逻辑——模型文件解压、模板融合(多次录音取均值)、阈值判定、偏好存储。
- UI层:提供注册页面、测试页面、调参页面。
数据流为:麦克风采集16kHz单声道S16LE PCM → C++侧静音裁切 + FBank特征提取(80维Mel滤波器×200帧) → ResNet34 ONNX模型输出256维嵌入向量 → L2归一化 → 与模板计算余弦相似度 → 与阈值比对输出结果。
一、C++ NAPI层:在鸿蒙上跑ONNX Runtime
鸿蒙的NAPI与Node.js的N-API基本同源,但部分细节有差异。通过napi_module_register注册名为“speakerverification”的模块,对外暴露7个函数:initExtractor、extractEmbeddingFromPcm、cosineSimilarity、getEmbeddingDim、isInitialized、getBackendName、isOrtCompileEnabled。ArkTS侧类型声明在index.d.ts中,导入时只需一行:
import speakerVerification from 'libspeakerverification.so';
注意:.so文件名必须与CMakeLists.txt中add_library的target名以及napi_module_register的nm_modname一致,否则真机上会报“Load native module failed”。
ONNX Runtime集成:条件编译与坑点
ONNX Runtime并非鸿蒙SDK自带,需要从OpenHarmony源码编译或获取预编译包。CMakeLists.txt中通过文件查找和预处理宏控制:
if(NOT DEFINED ONNXRUNTIME_ROOT_DIR)
set(ONNXRUNTIME_ROOT_DIR "D:/third_party/onnxruntime-ohos")
endif()
file(GLOB_RECURSE ORT_HEADER "${ONNXRUNTIME_ROOT_DIR}/**/onnxruntime_cxx_api.h")
file(GLOB_RECURSE ORT_SO "${ONNXRUNTIME_ROOT_DIR}/**/libonnxruntime.so")
C++侧使用条件编译:
#if defined(VOICEPRINT_USE_ORT) && VOICEPRINT_USE_ORT && __has_include("onnxruntime_cxx_api.h")
// 启用ONNX推理
#else
// Fallback:使用RMS+ZCR特征拼接,效果较差
#endif
这里有一个大坑:如果拿到的是Android版本的libonnxruntime.so,编译不会报错,但真机运行时会crash。CMake中专门检测路径是否包含“android”并给出警告。必须使用路径带“ohos”或“openharmony”标记的预编译包。若找不到,则自动fallback到简易方案,但余弦相似度波动很大(同一个人同一段话两次差异超过0.15)。
另外,动态链接时ONNX Runtime的.so文件SONAME通常为libonnxruntime.so.1,而非libonnxruntime.so。CMake中需要复制并重命名:
add_custom_command(TARGET speakerverification POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SV_ORT_SHARED_BUNDLE}"
"$<TARGET_FILE_DIR:speakerverification>/libonnxruntime.so.1"
COMMENT "copy ONNX as libonnxruntime.so.1 (SONAME) for HAP")
否则真机上dlopen直接失败,报“cannot find libonnxruntime.so.1”。
FBank特征提取:手搓信号处理
ONNX模型输入为80维×200帧的FBank特征。C++侧需自己将原始PCM转为FBank,核心步骤包括:
- 预加重:系数0.97,x = x - 0.97f * x。
- 分帧加窗:帧长25ms(400样点@16kHz),帧移10ms(160样点),Hamming窗。
- Mel滤波器组:80个三角滤波器,均匀分布在Mel频率轴上,相邻滤波器50%重叠。
- 功率谱计算:使用DFT(因为帧长512,手搓DFT即可),公式p = (re²+im²)/NFFT。
- 静音裁切:基于短时RMS,找出RMS超过峰值14%的第一帧和最后一帧,切除其余部分。14%是通过多次录音调参得到的经验值。
模型推理与相似度
ONNX Session初始化时优先从路径创建,失败则读整个文件到内存创建(部分设备路径加载有bug)。还使用FNV1a64检验文件完整性。模型输出256维向量经L2归一化后作为模板。验证时计算余弦相似度:
double DotCosine(const std::vector<float>& a, const std::vector<float>& b) {
double dot=0, na=0, nb=0;
for (size_t i=0; i<a.size(); i++) {
dot += a*b;
na += a*a;
nb += b*b;
}
return dot / (sqrt(na)*sqrt(nb));
}
二、ArkTS服务层:模型管理与模板融合
模型文件搬运
ONNX模型文件放在entry/src/main/resources/rawfile/目录下,Native侧需要沙箱文件系统绝对路径。因此需要将rawfile解压到filesDir/voiceprint_ort/子目录。这里有一个极易踩坑的点:fileIo.writeSync的WriteOptions.offset是“文件内绝对字节偏移”,不是“追加写入”。若每轮都设置offset:0,会反复覆盖文件头,导致最终文件大小只有最后一块大小,加载时报“Protobuf parsing failed”。正确做法是offset必须设置为当前已写入的绝对位置(累加值)。
另外,rawfile的getRawFdSync返回的offset和length是包内绝对位置,读取时需用lseek(SEEK_CUR)计算相对偏移。
const absPos: number = desc.offset + readedLen;
const curFp: number = fs.lseek(desc.fd, 0, LSEEK_CUR);
const relOff: number = absPos - curFp;
const len: number = fs.readSync(desc.fd, buffer, { offset: relOff, length: thisChunk });
模板融合与一致性检查
注册时需录2~3次声音,取嵌入向量的均值并再次L2归一化。融合前会检查当前录音与已有模板的余弦相似度,默认要求大于0.88,否则认为质量不过关,拒绝接受。
Pipeline版本控制
修改静音裁切参数(如TRIM_RELATIVE_RMS_FLOOR从0.12改为0.14)后,旧模板与新管线不匹配,验证相似度大幅下降。因此引入版本标识VOICEPRINT_PIPELINE_ID,加载模板时检查版本,若不匹配则自动删除旧模板并提示重新注册。
三、音频采集与TaskPool
音频采集固定为16kHz、单声道、S16LE格式。提供两种采集方案:
- VoiceprintPcmRecorder:定长录制,用于注册。
- VoiceprintRingBufferCapturer:环形缓冲,保留最近4秒PCM数据,用于提词器播放时的周期性声纹校验。
ONNX推理一次约80~150ms,必须放在子线程,否则UI卡顿。使用TaskPool调用@Concurrent函数,注意@Concurrent函数不能捕获模块级变量,只能使用import和局部变量。若TaskPool执行失败(如序列化问题),会静默降级到主线程执行,保证功能可用。
四、踩坑记录
1. ORT .so的SONAME问题:需将libonnxruntime.so复制为libonnxruntime.so.1。
2. mkdirSync的“File exists”异常:部分机型在目录已存在时抛出此错误,需先检查目录是否存在,若错误信息包含“File exists”且目录确实存在,则跳过。
3. 阈值调参:默认硬性下限0.90,低于0.90可能被他人误通过。可提供调参页面让用户微调,但下限不可修改。
4. 模型文件放置位置:不要放在oh-package.json5的依赖中,否则HAP包内没有rawfile目录。必须放在entry/src/main/resources/rawfile/下,通过resourceManager.getRawFdSync()读取并解压到沙箱。
五、效果总结
整套方案在真机上验证速度100~200ms,相似度稳定,能可靠区分本人与其他人。关键组件包括:C++ NAPI + ONNX Runtime手写FBank特征提取、ArkTS服务层的模板管理与版本控制、TaskPool子线程推理。本文提到的坑点(SONAME、offset语义、mkdir异常等)是实际开发中容易忽略的细节,供鸿蒙开发者参考。
Re: 鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话
不错的分享!最近也在调研鸿蒙上的端侧推理,ONNX Runtime的集成坑确实不少,特别是SONAME和.so文件名对齐的问题,您提到的复制重命名和路径检查很有参考价值。FBank特征提取手搓DFT也提到了细节,这部分以前在PC上做得比较多,搬到端侧还是要踩一遍功耗和实时性的坑。问一下,静音裁切那个14%阈值是在多少dBFS条件下调的?另外,模板融合时对多次录音的时长和信噪比有没有做筛选?Re: 鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话
感谢分享这么详细的鸿蒙端侧声纹识别落地经验。C++ NAPI + ONNX Runtime 的架构选型很扎实,从底层特征提取到上层应用逻辑拆得清晰,尤其把静态链接和 SONAME 的坑点都点出来了,这对后来者太有参考价值。 有几个点想请教: 1. FBank 特征提取手搓 DFT 而非用 FFT,是出于鸿蒙原生库依赖的考虑吗?短期帧长512点,DFT 开销应该还能接受,但真机上连续计算会不会影响 100~200ms 的延迟? 2. 静音裁切用 RMS 峰值14%这个阈值,是录制场景(近场、固定距离)下的经验值吗?如果环境噪声变化较大,是否需要动态调整或做 VAD 前置? 3. ResNet34 输出的256维向量,你们尝试过更轻量的网络(比如 ECAPA-TDNN 或 Mobilenet 变体)来降低端侧推理负载吗?还是说鸿蒙设备的算力足够支撑 ResNet34 的实时性? 4. 模板融合用的多次录音取均值,是否有考虑过异常值剔除(比如某次录音噪点过多导致向量偏移)?或者只是简单去平均后再做 L2 归一化? 再次感谢干货满满的实战分享,NAPI 和条件编译的 fallback 机制也可以作为鸿蒙原生能力渐进式集成的范例。Re: 鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话
楼主这个方案写得太详实了,从NAPI的模块注册、ONNX Runtime的条件编译坑点,到手搓FBank的每个参数,全是实用干货。尤其 “libonnxruntime.so.1” 那个SONAME问题,没有踩过坑真的想不到,感谢分享出来。想请教一下:你们最终使用的ResNet34 ONNX模型大概多大?在真机上首次加载耗时如何?另外,多用户场景下模板是怎么管理的——是每个用户一个文件还是统一存数据库?
页:
[1]