查看: 89|回复: 3

鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在提词器项目中,产品经理提出了一个需求:让提词器仅通过声纹识别判断是否为本人朗读,只有本人才触发翻页。本质上这是一个说话人验证(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中,导入时只需一行:
  1. 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中通过文件查找和预处理宏控制:
  1. if(NOT DEFINED ONNXRUNTIME_ROOT_DIR)
  2.     set(ONNXRUNTIME_ROOT_DIR "D:/third_party/onnxruntime-ohos")
  3. endif()
  4. file(GLOB_RECURSE ORT_HEADER "${ONNXRUNTIME_ROOT_DIR}/**/onnxruntime_cxx_api.h")
  5. file(GLOB_RECURSE ORT_SO "${ONNXRUNTIME_ROOT_DIR}/**/libonnxruntime.so")
复制代码
C++侧使用条件编译:
  1. #if defined(VOICEPRINT_USE_ORT) && VOICEPRINT_USE_ORT && __has_include("onnxruntime_cxx_api.h")
  2.     // 启用ONNX推理
  3. #else
  4.     // Fallback:使用RMS+ZCR特征拼接,效果较差
  5. #endif
复制代码
这里有一个大坑:如果拿到的是Android版本的libonnxruntime.so,编译不会报错,但真机运行时会crash。CMake中专门检测路径是否包含“android”并给出警告。必须使用路径带“ohos”或“openharmony”标记的预编译包。若找不到,则自动fallback到简易方案,但余弦相似度波动很大(同一个人同一段话两次差异超过0.15)。

另外,动态链接时ONNX Runtime的.so文件SONAME通常为libonnxruntime.so.1,而非libonnxruntime.so。CMake中需要复制并重命名:
  1. add_custom_command(TARGET speakerverification POST_BUILD
  2.     COMMAND ${CMAKE_COMMAND} -E copy_if_different
  3.     "${SV_ORT_SHARED_BUNDLE}"
  4.     "$<TARGET_FILE_DIR:speakerverification>/libonnxruntime.so.1"
  5.     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[i-1]。
- 分帧加窗:帧长25ms(400样点@16kHz),帧移10ms(160样点),Hamming窗。
- Mel滤波器组:80个三角滤波器,均匀分布在Mel频率轴上,相邻滤波器50%重叠。
- 功率谱计算:使用DFT(因为帧长512,手搓DFT即可),公式p[k] = (re²+im²)/NFFT。
- 静音裁切:基于短时RMS,找出RMS超过峰值14%的第一帧和最后一帧,切除其余部分。14%是通过多次录音调参得到的经验值。

模型推理与相似度

ONNX Session初始化时优先从路径创建,失败则读整个文件到内存创建(部分设备路径加载有bug)。还使用FNV1a64检验文件完整性。模型输出256维向量经L2归一化后作为模板。验证时计算余弦相似度:
  1. double DotCosine(const std::vector<float>& a, const std::vector<float>& b) {
  2.     double dot=0, na=0, nb=0;
  3.     for (size_t i=0; i<a.size(); i++) {
  4.         dot += a[i]*b[i];
  5.         na += a[i]*a[i];
  6.         nb += b[i]*b[i];
  7.     }
  8.     return dot / (sqrt(na)*sqrt(nb));
  9. }
复制代码

二、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)计算相对偏移。
  1. const absPos: number = desc.offset + readedLen;
  2. const curFp: number = fs.lseek(desc.fd, 0, LSEEK_CUR);
  3. const relOff: number = absPos - curFp;
  4. 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异常等)是实际开发中容易忽略的细节,供鸿蒙开发者参考。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话

不错的分享!最近也在调研鸿蒙上的端侧推理,ONNX Runtime的集成坑确实不少,特别是SONAME和.so文件名对齐的问题,您提到的复制重命名和路径检查很有参考价值。FBank特征提取手搓DFT也提到了细节,这部分以前在PC上做得比较多,搬到端侧还是要踩一遍功耗和实时性的坑。问一下,静音裁切那个14%阈值是在多少dBFS条件下调的?另外,模板融合时对多次录音的时长和信噪比有没有做筛选?
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

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 机制也可以作为鸿蒙原生能力渐进式集成的范例。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙端侧声纹识别:C++ NAPI + ONNX Runtime实现说话

楼主这个方案写得太详实了,从NAPI的模块注册、ONNX Runtime的条件编译坑点,到手搓FBank的每个参数,全是实用干货。尤其 “libonnxruntime.so.1” 那个SONAME问题,没有踩过坑真的想不到,感谢分享出来。想请教一下:你们最终使用的ResNet34 ONNX模型大概多大?在真机上首次加载耗时如何?另外,多用户场景下模板是怎么管理的——是每个用户一个文件还是统一存数据库?
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-22 20:36 , Processed in 0.025169 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部