鸿蒙 PC 三方库适配复盘:libfvad 1.0 —— 把 WebRTC VAD 做成能装进采集链路的静态库
本文是开源鸿蒙 PC 专项积分赛「实战复盘 · 路径 C」方向的第 4 篇。
对应 PR:https://atomgit.com/OpenHarmonyPCDeveloper/build_in_harmonyos/merge_requests/19802
归档目录:archives/l/libfvad/1.0/
一、为什么选 libfvad
前一篇复盘里我们把 soundio 2.0.0 适配进了鸿蒙 PC,解决了「音频怎么进出设备」的问题。
但一个真实的语音应用链路是这样的:
麦克风采集(soundio) → 这一帧到底有没有人说话?(VAD) → 唤醒/发包/推理中间那一步就是 VAD(Voice Activity Detection,语音活动检测)。Android、iOS、Windows
上它基本是系统能力,鸿蒙 PC 上没有对等的公开 API,所以只能自己带库。
libfvad 是 WebRTC VAD 算法的独立 C 提取版:
| 维度 | libfvad | 说明 |
|---|---|---|
| 依赖 | 标准 C 库 + libm | 没有 sndfile / fftw / 任何三方依赖 |
| 体积 | 源码 ~300 KB,产物静态库 ~150 KB | 适合塞进应用侧 |
| API | 4 个函数:fvad_new/free/set_mode/set_sample_rate/process | 心智负担接近零 |
| 许可 | BSD-3-Clause | 商用友好 |
对鸿蒙 PC 来说它还有个额外好处:它不碰任何平台音频子系统。它只吃int16_tPCM 帧,所以在没有完整音频 HAL 的环境里也能跑通、能测通。
二、上游工程结构(决定了配方怎么写)
从 tag 拉下来的目录是这样的:
libfvad-1.0/ ├── configure.ac # autotools 工程 ├── Makefile.am ├── libfvad.pc.in # 会生成 pkg-config 文件 ├── include/fvad.h # 唯一公共头文件 ├── src/ │ ├── Makefile.am # 内部 libfvad_int.la + 公共 libfvad.la │ ├── fvad.c │ ├── signal_processing/ │ └── vad/ ├── tests/ # 13 个 check_PROGRAMS + wavtest.sh └── examples/ # fvadwav,可选,依赖 libsndfile三个关键事实:
- git 归档里没有
configure。GitHub 的 tag tarball 不含 autotools 生成物,
必须先autoreconf -fi。 configure.ac里写着LT_INIT([disable-static pic-only])——默认只产.so。tests/Makefile.am里TESTS = 13 个 check_PROGRAMS + wavtest.sh,共 14 项。
三、配方设计的三个决策(以及为什么)
本节先给出整条构建链路的流程图,再逐一拆解三个关键决策。图中每个节点都对应配方里的一段代码,读图时可以先建立整体印象,再看细节。
先看整条构建链路,后面三个决策分别对应图中的不同节点:
上图可以按「准备 → 配置 → 编译 → 产物 → 验证」五段来读:
- 准备段(A→B):tag 归档里没有
configure,所以先autoreconf -fi生成构建脚本,这一步依赖tool_requires补进来的 automake 与 libtool。 - 配置段(C):
configure阶段把--target/--sysroot同时注入 cflags 与 ldflags,并用--disable-examples切断 libsndfile 这条可选依赖。 - 编译段(D):固定
make -j1,规避 HMDFS 上 jobserver fifo 的权限问题。 - 产物段(E):最终得到
libfvad.a、fvad.h、libfvad.pc三件套。 - 验证段(F):由
test_package交叉编译链接消费者程序,承担「适配后能不能用」的证明责任。
交叉构建下make check被跳过:上游 14 项测试全是目标架构二进制,宿主跑不了,
所以build()里用cross_building(self)门控,只在非交叉构建时执行make check;
「适配后能不能用」改由末端的test_package承担——它用被测包导出的头文件和库
编译链接一个消费者程序,交叉构建时只编译链接,真机/CI 上再执行。
决策 1:autoreconf -fi+ tool_requires 补齐 automake/libtool
CI worker 上只有 autoconf 家族(autoconf/autoheader/autom4te),没有automake、aclocal、libtoolize。直接跑autoreconf -fi会挂在aclocal: command not found。
所以配方里显式声明构建期依赖:
defbuild_requirements(self):# git 归档没有 configure:bootstrap 需要 automake + libtoolize。# 这两个包以 build profile 解析,因此 CI 需 -pr:h/-pr:b 双 profile。self.tool_requires("automake/1.18")self.tool_requires("libtool/2.5.4")defgenerate(self):tc=AutotoolsToolchain(self)# 关键:--target / --sysroot 同时进 cflags 与 ldflags,见决策 3flags=[f"--target={self.settings.arch}-linux-ohos"]sysroot=self.conf.get("tools.build:sysroot",check_type=str)ifsysroot:flags.append(f"--sysroot={sysroot}")tc.cflags.extend(flags)tc.cxxflags.extend(flags)tc.ldflags.extend(flags)tc.configure_args+=["--disable-examples",# 见坑 4:别拖进 libsndfile"--disable-dependency-tracking",]env=tc.environment()env.define("NOCONFIGURE","1")# 防止 configure.ac 里自动重跑 autogentc.generate()defbuild(self):# 归档里没有 configure,先 bootstrapself.run("autoreconf -fi",env="conanbuild")autotools=Autotools(self)autotools.configure()autotools.make(args=["-j1"])# 见坑 3:HMDFS 上 -jN 会 EPERM为什么不用 patch 塞一个现成的 configure?
生成的configure有 20 万行、带时间戳与宿主路径,塞进归档既不可评审也不可复现。
让构建时 bootstrap,才是与上游一致的做法。
决策 2:默认产静态库,而不是.so
上游LT_INIT([disable-static pic-only])的意思是「我要动态库」,但鸿蒙应用侧的
实际情况反过来:
.so放进 HAP 后必须带签名段才能dlopen,否则Permission denied;
签名要额外调binary-sign-tool,产物不可移植;- 静态库直接链进应用的可执行文件,没有部署路径、没有签名、没有 soname 问题;
- VAD 是个纯计算库,不存在「多进程共享一份」的收益。
因此配方暴露shared选项并默认False,交给AutotoolsToolchain自动生成--disable-shared --enable-static(它会把disable-static的默认值覆盖回来):
options={"shared":[True,False],"fPIC":[True,False]}default_options={"shared":False,"fPIC":True}决策 3:--target/--sysroot必须同时进 cflags 和 ldflags
这是最容易踩的坑。交叉编译时如果只把--target=aarch64-linux-ohos加到cflags,configure的链接探针(那些checking whether the C compiler works...)
仍然会用宿主链接器。结果是「编译过了」,但产物是宿主架构——测试在设备上直接Exec format error。
所以:
flags=[f"--target={target}"]ifsysroot:flags.append(f"--sysroot={sysroot}")tc.cflags.extend(flags)# 编译tc.cxxflags.extend(flags)tc.ldflags.extend(flags)# 链接:缺这一行就会出现「假通过」--host用aarch64-linux-ohos(config.sub 内置*-linux-ohos模式),
与 clang 的 triple 共用同一个字符串,避免cannot guess build type。
四、踩坑记录
坑 1:tag 根本不存在
第一版conandata.yml我按直觉写了v1.3.0,下载 404。查 GitHub tags API 才发现
这个仓库只有v1.0一个 tag:
curl-H'User-Agent: curl/8'https://api.github.com/repos/dpirch/libfvad/tags# [{"name":"v1.0", ...}]教训:写conandata.yml之前必须用 API 或git ls-remote --tags确认 tag 真实存在,
不要凭版本号直觉拼 URL。
坑 2:交叉编译不能跑make check
上游 14 项测试全是目标架构二进制,宿主跑不了。所以:
autotools.make(args=["-j1"])ifnotcross_building(self):autotools.make(target="check",args=["-j1"])那「适配后到底能不能用」靠什么证明?靠test_package——它是 Conan 标准机制:conan create时用被测包导出的头文件和库编译一个消费者程序,交叉构建时只编译
链接,真机/CI 上再执行。
坑 3:make -j在 HMDFS 上会 EPERM
make -jN依赖 jobserver fifo,HMDFS(鸿蒙分布式文件系统)对 fifo 的权限处理不同,
会直接Permission denied。所以整个仓库的配方都固定-j1。
坑 4:--enable-examples会拖进 libsndfile
上游的fvadwav示例要读 wav 文件,configure --enable-examples会走PKG_CHECK_MODULES([sndfile], [sndfile])。VAD 本体根本不需要它,所以显式--disable-examples,别让一条可选路径污染依赖链。
五、测试设计:12 项断言,覆盖「数值 + 边界」
test_package/test.c不打印任何「hello」,全部是对库行为的断言:
| # | 断言 | 为什么要测 |
|---|---|---|
| 1 | fvad_new()非 NULL | 链接到fvad_*符号(不是只编过头文件) |
| 2 | fvad_set_mode(0..3)返回 0 | 合法区间 |
| 3 | fvad_set_mode(4)返回 -1 | 边界必须拒绝,否则上层传错值会静默降级 |
| 4 | fvad_set_sample_rate(8000/16000/32000/48000)返回 0 | 四个合法采样率全覆盖 |
| 5 | fvad_set_sample_rate(44100)返回 -1 | 44100 是「看起来合理」的错误值,最容易漏测 |
| 6 | 全零 20ms 帧(160 样本 @8k)→ 0 | 静音判定 |
| 7 | 100 样本(非法帧长)→ -1 | 上游对帧长硬校验 |
| 8 | 未set_sample_rate直接process→ -1 | 状态机前置条件 |
| 9 | fvad_reset()后仍可用 | 状态机不残留 |
| 10 | 同一帧连续process两次结果一致 | 无隐藏内部状态漂移 |
| 11 | 正弦波 1kHz 帧 → 1 | 有声判定(不只是「不崩」) |
| 12 | fvad_free()不崩 | 生命周期闭环 |
这张表可以按三类来读:接口契约(1–5)验证参数校验与边界拒绝,行为正确性(6、11)用静音帧与正弦波帧两头钉住判定结果,状态机与生命周期(7–10、12)覆盖非法帧长、前置条件、reset 与释放。
12 项断言、退出码非 0 即失败,CI 直接判定。其中 6 与 11 是一对:只测静音会漏掉
「永远返回 0」的实现,只测有声会漏掉「永远返回 1」的实现,必须两头都钉住。
六、结果
开发界面如下:
运行效果如下:
- PR:#19802
- 归档:
archives/l/libfvad/1.0/(conanfile.py / conandata.yml / manifest.yaml /
notes.md / test_package 三件套) - 构建产物:
libfvad.a(约 148 KB,aarch64-linux-ohos)+fvad.h+libfvad.pc - 上游测试清点:14 项(13 unittest + wavtest.sh),源文件与命令逐条登记在
test_package/MANIFEST.yml - 新增消费者测试:12 项断言,交叉构建下编译链接通过,真机执行全绿
- 依赖树:
conan graph info显示运行时依赖为空(仅 libc / libm),
构建期依赖为 automake + libtool,不进最终产物
七、总结
这次 libfvad 1.0 的适配,本质上不是「把一个库编译过去」,而是把一条可复现的构建链路固化下来。回看整个过程,可以提炼成四点:
- 先读上游,再写配方。
configure.ac里的LT_INIT([disable-static pic-only])、tests/Makefile.am里的 14 项测试、examples/对 libsndfile 的依赖,这三处信息直接决定了后面三个决策,比试错快得多。 - 归档里没有的东西,构建时补。
configure不进归档,改用autoreconf -fi+tool_requires,既保持与上游一致,也让产物可评审、可复现。 - 交叉编译的坑集中在「链接」而不是「编译」。
--target/--sysroot只进 cflags 会得到「假通过」的宿主产物,必须同时进 ldflags;make check同理,宿主跑不了目标二进制,只能靠test_package兜底。 - 验证要能证伪。12 项断言里既有合法值也有非法值,既有静音也有有声,任何一侧缺失都会让「永远返回 0」或「永远返回 1」的实现蒙混过关。
从结果看,产物是约 148 KB 的libfvad.a加一个头文件和一个 pkg-config 文件,运行时依赖为空,构建期依赖不进最终产物——这正是「能装进采集链路」的形态。下一篇复盘换一种构建系统:LibXtract 是纯手写 Makefile,没有 configure,坑全在变量优先级和平台探测上,那是另一套完全不同的坑。