鸿蒙 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_t PCM 帧,所以在没有完整音频 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

三个关键事实:

  1. git 归档里没有 configure 。GitHub 的 tag tarball 不含 autotools 生成物,
    必须先 autoreconf -fi。
  2. configure.ac 里写着 LT_INIT([disable-static pic-only])------默认只产 .so。
  3. tests/Makefile.am 里 TESTS = 13 个 check_PROGRAMS + wavtest.sh,共 14 项。

三、配方设计的三个决策(以及为什么)

本节先给出整条构建链路的流程图,再逐一拆解三个关键决策。图中每个节点都对应配方里的一段代码,读图时可以先建立整体印象,再看细节。

先看整条构建链路,后面三个决策分别对应图中的不同节点:
#mermaid-svg-kTpT3e5ylwXGYLcH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kTpT3e5ylwXGYLcH .error-icon{fill:#552222;}#mermaid-svg-kTpT3e5ylwXGYLcH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kTpT3e5ylwXGYLcH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kTpT3e5ylwXGYLcH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kTpT3e5ylwXGYLcH .marker.cross{stroke:#333333;}#mermaid-svg-kTpT3e5ylwXGYLcH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kTpT3e5ylwXGYLcH p{margin:0;}#mermaid-svg-kTpT3e5ylwXGYLcH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster-label text{fill:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster-label span{color:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster-label span p{background-color:transparent;}#mermaid-svg-kTpT3e5ylwXGYLcH .label text,#mermaid-svg-kTpT3e5ylwXGYLcH span{fill:#333;color:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH .node rect,#mermaid-svg-kTpT3e5ylwXGYLcH .node circle,#mermaid-svg-kTpT3e5ylwXGYLcH .node ellipse,#mermaid-svg-kTpT3e5ylwXGYLcH .node polygon,#mermaid-svg-kTpT3e5ylwXGYLcH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kTpT3e5ylwXGYLcH .rough-node .label text,#mermaid-svg-kTpT3e5ylwXGYLcH .node .label text,#mermaid-svg-kTpT3e5ylwXGYLcH .image-shape .label,#mermaid-svg-kTpT3e5ylwXGYLcH .icon-shape .label{text-anchor:middle;}#mermaid-svg-kTpT3e5ylwXGYLcH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kTpT3e5ylwXGYLcH .rough-node .label,#mermaid-svg-kTpT3e5ylwXGYLcH .node .label,#mermaid-svg-kTpT3e5ylwXGYLcH .image-shape .label,#mermaid-svg-kTpT3e5ylwXGYLcH .icon-shape .label{text-align:center;}#mermaid-svg-kTpT3e5ylwXGYLcH .node.clickable{cursor:pointer;}#mermaid-svg-kTpT3e5ylwXGYLcH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kTpT3e5ylwXGYLcH .arrowheadPath{fill:#333333;}#mermaid-svg-kTpT3e5ylwXGYLcH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kTpT3e5ylwXGYLcH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kTpT3e5ylwXGYLcH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kTpT3e5ylwXGYLcH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kTpT3e5ylwXGYLcH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kTpT3e5ylwXGYLcH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster text{fill:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH .cluster span{color:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kTpT3e5ylwXGYLcH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kTpT3e5ylwXGYLcH rect.text{fill:none;stroke-width:0;}#mermaid-svg-kTpT3e5ylwXGYLcH .icon-shape,#mermaid-svg-kTpT3e5ylwXGYLcH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kTpT3e5ylwXGYLcH .icon-shape p,#mermaid-svg-kTpT3e5ylwXGYLcH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kTpT3e5ylwXGYLcH .icon-shape .label rect,#mermaid-svg-kTpT3e5ylwXGYLcH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kTpT3e5ylwXGYLcH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kTpT3e5ylwXGYLcH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kTpT3e5ylwXGYLcH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tag 归档(无 configure)
autoreconf -fi(tool_requires automake/libtool)
configure(--target/--sysroot 进 cflags+ldflags、--disable-examples)
make -j1
libfvad.a + fvad.h + libfvad.pc
test_package 交叉编译链接

上图可以按「准备 → 配置 → 编译 → 产物 → 验证」五段来读:

  • 准备段(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。

所以配方里显式声明构建期依赖:

python 复制代码
def build_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")

def generate(self):
    tc = AutotoolsToolchain(self)
    # 关键:--target / --sysroot 同时进 cflags 与 ldflags,见决策 3
    flags = [f"--target={self.settings.arch}-linux-ohos"]
    sysroot = self.conf.get("tools.build:sysroot", check_type=str)
    if sysroot:
        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 里自动重跑 autogen
    tc.generate()

def build(self):
    # 归档里没有 configure,先 bootstrap
    self.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 的默认值覆盖回来):

python 复制代码
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。

所以:

python 复制代码
flags = [f"--target={target}"]
if sysroot:
    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:

bash 复制代码
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 项测试全是目标架构二进制,宿主跑不了。所以:

python 复制代码
autotools.make(args=["-j1"])
if not cross_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 的适配,本质上不是「把一个库编译过去」,而是把一条可复现的构建链路固化下来。回看整个过程,可以提炼成四点:

  1. 先读上游,再写配方 。configure.ac 里的 LT_INIT([disable-static pic-only])、tests/Makefile.am 里的 14 项测试、examples/ 对 libsndfile 的依赖,这三处信息直接决定了后面三个决策,比试错快得多。
  2. 归档里没有的东西,构建时补 。configure 不进归档,改用 autoreconf -fi + tool_requires,既保持与上游一致,也让产物可评审、可复现。
  3. 交叉编译的坑集中在「链接」而不是「编译」 。--target/--sysroot 只进 cflags 会得到「假通过」的宿主产物,必须同时进 ldflags;make check 同理,宿主跑不了目标二进制,只能靠 test_package 兜底。
  4. 验证要能证伪。12 项断言里既有合法值也有非法值,既有静音也有有声,任何一侧缺失都会让「永远返回 0」或「永远返回 1」的实现蒙混过关。

从结果看,产物是约 148 KB 的 libfvad.a 加一个头文件和一个 pkg-config 文件,运行时依赖为空,构建期依赖不进最终产物------这正是「能装进采集链路」的形态。下一篇复盘换一种构建系统:LibXtract 是纯手写 Makefile,没有 configure,坑全在变量优先级和平台探测上,那是另一套完全不同的坑。

相关推荐
Ai-_Man1 小时前
Coze扣子还是不可以导出左侧所有对话,只能点进去单条内容导出,方法不对吗?
开发语言·人工智能·ai·小程序·1024程序员节
aqi001 小时前
AI编程让鸿蒙开发电子书APP更加快捷
android·华为·harmonyos·鸿蒙·harmony
raindayinrain1 小时前
c++设计模式
开发语言·c++·设计模式
hiahiahia1231 小时前
Streaming Model Output
开发语言·前端
Ruiery2 小时前
Linux 6.6内核内存管理深度解析(十七):vmalloc — 内核要“虚拟连续“就够:物理页逐个凑,页表来连
linux·运维·服务器
熊猫钓鱼>_>2 小时前
鸿蒙PC高分屏与键鼠交互适配:DPI缩放、右键菜单一次讲清
开发语言·笔记·华为·ai编程·harmonyos·鸿蒙·鸿蒙心迹
R²AIN SUITE2 小时前
企业智能体平台私有化部署需要什么服务器?CPU、内存、显卡与项目管理集成的7个评估项
运维·服务器
坚定的共产主义生产设备永不宕机2 小时前
服务器从裸机开始搭建ESXI虚拟机
linux·服务器·网络