在提词器项目中,产品经理提出了一个需求:让提词器仅通过声纹识别判断是否为本人朗读,只有本人才触发翻页。本质上这是一个说话人验证(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[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归一化后作为模板。验证时计算余弦相似度:- 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[i]*b[i];
- na += a[i]*a[i];
- nb += b[i]*b[i];
- }
- 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异常等)是实际开发中容易忽略的细节,供鸿蒙开发者参考。 |