audio-cpp-sys 0.1.0

audio.cpp(ggml 音频推理框架)的底层绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
use std::env;
use std::path::{Path, PathBuf};

use cmake::Config;
use glob::glob;

/// 通过 BUILD_DEBUG 环境变量控制构建脚本调试日志输出。
macro_rules! debug_log {
    ($($arg:tt)*) => {
        if std::env::var("BUILD_DEBUG").is_ok() {
            println!("cargo:warning=[DEBUG] {}", format!($($arg)*));
        }
    };
}

/// audio.cpp 上游源码树位于 `$CARGO_MANIFEST_DIR/audio.cpp`。
///
/// 该目录通常是 git submodule(`.gitmodules` 指向
/// `https://github.com/0xShug0/audio.cpp.git`),内容不提交进本仓库。
/// 克隆本项目后需先 `git submodule update --init --recursive`。
fn audio_src_dir() -> PathBuf {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR 未设置");
    Path::new(&manifest_dir).join("audio.cpp")
}

/// 上游源码 URL(crates.io 打包场景没有 .git 上下文,无法走 submodule,
/// 需要用本 URL 直接 clone)。
const AUDIO_CPP_URL: &str = "https://github.com/0xShug0/audio.cpp.git";

/// 确保 `audio.cpp` 源码树存在;缺失时自动获取。
///
/// 返回包含源码树的路径。获取策略:
/// 1. 源码已存在(`$CARGO_MANIFEST_DIR/audio.cpp`)→ 直接返回;
/// 2. 处于 git 仓库内(含 `.gitmodules`)→ `git submodule update --init`;
/// 3. 否则(如 crates.io 打包验证场景,manifest 目录只读)→ `git clone --depth 1`
///    到 `OUT_DIR/audio.cpp`(build.rs 只允许写 OUT_DIR)。
fn ensure_audio_src() -> PathBuf {
    let manifest_src = audio_src_dir();
    if manifest_src.join("CMakeLists.txt").exists() {
        return manifest_src;
    }

    // 从 manifest 目录向上找 .git,判断是否处于 git 仓库。
    let manifest_dir = env::var("CARGO_MANIFEST_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| manifest_src.clone());
    let mut dir = manifest_dir.clone();
    let mut in_git_repo = false;
    loop {
        if dir.join(".git").exists() {
            in_git_repo = true;
            break;
        }
        if !dir.pop() {
            break;
        }
    }

    if in_git_repo {
        debug_log!("audio.cpp 缺失,执行 git submodule update --init ...");
        let status = std::process::Command::new("git")
            .args(["submodule", "update", "--init", "--recursive"])
            .current_dir(&manifest_dir)
            .status()
            .expect("failed to run git submodule update");
        if status.success() && manifest_src.join("CMakeLists.txt").exists() {
            return manifest_src;
        }
        debug_log!("submodule 更新失败,回退到 git clone");
    }

    // 不在 git 仓库(或 submodule 不可用):clone 到 OUT_DIR。
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR 未设置"));
    let src_dir = out_dir.join("audio.cpp");
    if !src_dir.join("CMakeLists.txt").exists() {
        debug_log!("audio.cpp 缺失,执行 git clone --depth 1 {AUDIO_CPP_URL} 到 OUT_DIR ...");
        let status = std::process::Command::new("git")
            .args(["clone", "--depth", "1", AUDIO_CPP_URL])
            .arg(&src_dir)
            .status()
            .expect("failed to run git clone");
        assert!(
            status.success() && src_dir.join("CMakeLists.txt").exists(),
            "无法自动获取 audio.cpp 源码。请确认网络可用,或手动把源码放到 {}",
            manifest_src.display()
        );
    }
    src_dir
}

/// 从 Rust 目标三元组推断出粗粒度的操作系统类别,用于决定静态库的
/// 文件后缀(Windows 为 .lib,其余为 .a)与链接提示。
fn target_os() -> String {
    let target = env::var("TARGET").unwrap_or_default();
    if target.contains("windows") {
        "windows".to_string()
    } else if target.contains("apple") {
        "apple".to_string()
    } else if target.contains("android") {
        "android".to_string()
    } else if target.contains("linux") {
        "linux".to_string()
    } else {
        target
    }
}

/// 收集 audio.cpp CMake 构建产出的静态库文件名(去掉前缀/后缀)。
///
/// audio.cpp 产出:`engine_runtime`、`ggml`、`ggml-cpu`、`ggml-base`、
/// `sentencepiece-static`(别名 `sentencepiece`)、`cjson_vendor`、
/// `yaml_vendor`,以及可选的各后端库。cmake crate 把归档库放在
/// `OUT_DIR/build`(及其子目录),因此对给定的搜索目录做递归 glob,
/// 并按 stem 去重。链接顺序由各库的相互依赖决定(engine_runtime 依赖
/// ggml 系列与 sentencepiece 等),这里按发现顺序输出即可,Rust 链接器
/// 会按需求遍历。
fn extract_static_lib_names(search_dirs: &[PathBuf], os: &str) -> Vec<String> {
    let ext = match os {
        "windows" => "*.lib",
        _ => "*.a",
    };
    let mut names: Vec<String> = Vec::new();
    for dir in search_dirs {
        let pattern = dir.join("**").join(ext).to_string_lossy().into_owned();
        for entry in glob(&pattern).expect("构建 lib glob 失败") {
            let Ok(path) = entry else { continue };
            let Some(stem) = path.file_stem() else { continue };
            let mut name = stem.to_string_lossy().into_owned();
            if !name.starts_with("lib") && path.extension().map(|e| e == "a").unwrap_or(false) {
                // MinGW 下无 lib 前缀的归档由构建过程处理,这里保留原始 stem。
            }
            if name.starts_with("lib") {
                name = name.strip_prefix("lib").unwrap_or(&name).to_string();
            }
            if name.ends_with("-static") {
                name = name.strip_suffix("-static").unwrap_or(&name).to_string();
            }
            if !names.contains(&name) {
                names.push(name);
            }
        }
    }
    names
}

/// 为发现的每个静态库输出 `cargo:rustc-link-lib=static=` 指令。
fn link_static_libs(names: &[String]) {
    for name in names {
        println!("cargo:rustc-link-lib=static={}", name);
    }
}

/// 收集"以 feature 方式启用的模型族"。
///
/// Cargo 会把每个启用的 feature 以环境变量 `CARGO_FEATURE_<名>`(名字中的
/// `-` 映射为 `_`,全大写)注入 build.rs。本项目约定模型族 feature 一律以
/// `model-` 为前缀(如 `model-qwen3-asr`),其对应环境变量即为
/// `CARGO_FEATURE_MODEL_QWEN3_ASR`。拿到后缀后大写→小写即是上游 CMake 的
/// alias/target 名(如 `qwen3_asr`),因此新增模型族 feature 时无需改动
/// build.rs,只要在 Cargo.toml 里声明的名字与上游 target/alias 一致。
fn enabled_model_features() -> Vec<String> {
    let mut names = Vec::new();
    for (key, _) in env::vars() {
        if let Some(suffix) = key.strip_prefix("CARGO_FEATURE_MODEL_") {
            names.push(suffix.to_lowercase());
        }
    }
    names.sort();
    names
}

/// 把 feature 名(`citrinet_asr` 等)与 `AUDIOCPP_MODELS` 环境变量的内容
/// 合并去重,作为传给 CMake 的 `AUDIOCPP_MODELS` 取值。
fn merge_custom_models(feature_names: Vec<String>) -> String {
    let mut all: Vec<String> = feature_names;
    if let Ok(env_models) = env::var("AUDIOCPP_MODELS") {
        for m in env_models.split(',').map(str::trim).filter(|s| !s.is_empty()) {
            if !all.iter().any(|s| s == m) {
                all.push(m.to_string());
            }
        }
    }
    all.join(",")
}

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=Cargo.toml"); // feature 组合变化(model-* 增删)会重跑
    println!("cargo:rerun-if-changed=capi.h");
    println!("cargo:rerun-if-changed=capi.cpp");

    let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR 未设置");
    let manifest_dir = PathBuf::from(&manifest_dir);
    let src_dir = ensure_audio_src();

    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let os = target_os();

    // ------------------------------------------------------------------
    // 1. 用 CMake 构建 audio.cpp 的 engine_runtime 静态库。
    //    强制使用 Ninja 生成器,保证单一配置(single-config)的输出布局
    //    (归档直接落在 OUT_DIR/lib 下),不受宿主机默认生成器影响。
    // ------------------------------------------------------------------
    let mut config = Config::new(&src_dir);
    config.generator("Ninja");

    // MSVC 目标下全局注入编译选项:
    //   - /utf-8  —— audio.cpp 源码是 UTF-8 无 BOM,MSVC 默认按 ANSI 代码页
    //                解析,含中文文本的源(如 chinese_normalization.cpp)会报
    //                C2001;上游只给个别文件加了此选项,这里对所有目标生效。
    //   - /EHsc   —— 启用 C++ 异常展开语义,避免 C4530 警告。
    if os == "windows" {
        config.cxxflag("/utf-8").cxxflag("/EHsc");
        config.cflag("/utf-8");
    }

    // 只构建库本身,关闭示例/测试/benchmark,避免无关目标进入构建图。
    config.define("ENGINE_BUILD_EXAMPLES", "OFF");
    config.define("ENGINE_BUILD_TESTS", "OFF");
    config.define("ENGINE_BUILD_WARMBENCH", "OFF");
    config.define("AUDIOCPP_DEPLOYMENT_BUILD", "OFF");
    config.define("SPM_BUILD_TEST", "OFF");
    config.define("SPM_ENABLE_SHARED", "OFF");

    // 后端选项由 Cargo feature 映射(与工作区 feature 划分保持一致)。
    config.define("ENGINE_ENABLE_CUDA", if cfg!(feature = "cuda") { "ON" } else { "OFF" });
    config.define("ENGINE_ENABLE_HIP", if cfg!(feature = "hip") { "ON" } else { "OFF" });
    config.define("ENGINE_ENABLE_VULKAN", if cfg!(feature = "vulkan") { "ON" } else { "OFF" });
    let metal_on = cfg!(feature = "metal") || (os == "apple");
    config.define("ENGINE_ENABLE_METAL", if metal_on { "ON" } else { "OFF" });
    config.define("ENGINE_ENABLE_OPENMP", if cfg!(feature = "openmp") { "ON" } else { "OFF" });
    config.define("ENGINE_ENABLE_NATIVE_CPU", if cfg!(feature = "native") { "ON" } else { "OFF" });

    // 把所有静态归档统一输出到 OUT_DIR/lib,便于后续 glob 收集与链接。
    // audio.cpp 的 CMake 未设置 archive 输出目录,归档默认散落在各
    // target 的构建子目录(ggml/src、external/sentencepiece/src 等)。
    config.define(
        "CMAKE_ARCHIVE_OUTPUT_DIRECTORY",
        out_dir.join("lib").to_string_lossy().into_owned(),
    );

    // 模型组合选择(映射 AUDIOCPP_MODEL_SET:full / core / custom)。
    let model_set = if cfg!(feature = "full-models") {
        "full"
    } else if cfg!(feature = "custom-models") {
        // custom:只编译指定的模型族。来源有二,且会取并集:
        //   1. `model-<族>` feature(如 model-qwen3-asr)——build.rs 扫描
        //      CARGO_FEATURE_MODEL_* 自动收集,无需手动设置环境变量;
        //   2. `AUDIOCPP_MODELS` 环境变量(逗号分隔的 alias/target 名)。
        // 引擎核心 + 内置 VAD 始终编入,见上游 CMakeLists 的 AUDIOCPP_RUNTIME_OBJECTS。
        let requested = merge_custom_models(enabled_model_features());
        if requested.is_empty() {
            panic!(
                "feature `custom-models` 未指定任何模型族。请至少先启用一个 \
                 `model-<族>` feature(如 --features model-qwen3-asr),或设置 \
                 环境变量 AUDIOCPP_MODELS(逗号分隔的模型族目标,如 \
                 AUDIOCPP_MODELS=qwen3_asr,citrinet_asr)"
            );
        }
        println!("cargo:rerun-if-env-changed=AUDIOCPP_MODELS");
        config.define("AUDIOCPP_MODELS", &requested);
        debug_log!("AUDIOCPP_MODELS(合并后)={}", requested);
        "custom"
    } else {
        "core"
    };
    config.define("AUDIOCPP_MODEL_SET", model_set);

    // 透传 GGML_*/CMAKE_* 环境变量,方便下游用户按需微调 ggml 选项,
    // 而无需修改本脚本。优先级低于脚本中显式设置的选项。
    for (key, value) in env::vars() {
        if key.starts_with("GGML_") || key.starts_with("CMAKE_") {
            println!("cargo:rerun-if-env-changed={key}");
            config.define(&key, &value);
        }
    }

    // 用 cc crate 探测 MSVC 编译器,把正确解析出的 INCLUDE/LIB 环境注入
    // CMake 子进程。未运行 vcvarsall 的普通 shell 下,CMake 直接调 cl.exe
    // 会因缺少 INCLUDE 而找不到标准头(stdbool.h 等)。
    if os == "windows" {
        let cc = cc::Build::new();
        let compiler = cc.try_get_compiler().expect("探测 C 编译器失败");
        for (key, value) in compiler.env().iter().filter(|(k, _)| {
            k.eq_ignore_ascii_case("INCLUDE")
                || k.eq_ignore_ascii_case("LIB")
                || k.eq_ignore_ascii_case("PATH")
        }) {
            debug_log!(
                "注入 MSVC 环境变量 {}={}",
                key.to_string_lossy(),
                value.to_string_lossy()
            );
            config.env(key, value);
        }
    }

    let profile = env::var("AUDIOCPP_LIB_PROFILE").unwrap_or_else(|_| "Release".to_string());
    let build_dir = config
        .profile(&profile)
        .build_target("engine_runtime")
        .very_verbose(env::var("CMAKE_VERBOSE").is_ok())
        // 每次构建都重新 configure:CMake 会在 configure 时根据
        // AUDIOCPP_MODEL_SET / AUDIOCPP_MODELS 重新生成 registry.inc,
        // 因此切换模型组合后能正确更新注册的 loader 集合(Ninja 只会
        // 重编受影响的 registry.cpp 及链接)。
        .always_configure(true)
        .build();

    println!("cargo:rerun-if-env-changed=AUDIOCPP_LIB_PROFILE");

    // cmake crate 会把归档放在 OUT_DIR/lib(及 lib64);多配置生成器下
    // 还会出现 OUT_DIR/lib/<Config> 子目录。全部加入链接搜索路径。
    let mut search_dirs = vec![
        out_dir.join("lib"),
        out_dir.join("lib64"),
        build_dir.clone(),
    ];
    debug_log!("out_dir={} build_dir={}", out_dir.display(), build_dir.display());
    search_dirs.retain(|d| d.is_dir());
    for cfg in ["Release", "RelWithDebInfo", "Debug"] {
        for base in [&out_dir, &build_dir] {
            let d = base.join("lib").join(cfg);
            if d.is_dir() {
                search_dirs.push(d);
            }
        }
    }
    let mut seen: Vec<PathBuf> = Vec::new();
    for d in &search_dirs {
        if !seen.contains(d) {
            println!("cargo:rustc-link-search=native={}", d.display());
            seen.push(d.clone());
        }
    }

    let lib_names = extract_static_lib_names(&search_dirs, &os);
    assert!(
        lib_names.iter().any(|n| n == "engine_runtime"),
        "在 OUT_DIR 下未找到 engine_runtime 静态库(找到: {:?})",
        lib_names
    );
    link_static_libs(&lib_names);
    debug_log!("发现的静态库: {:?}", lib_names);

    // 平台系统库:ggml-cpu 在 Windows 上通过注册表查询 CPU 特性(advapi32),
    // 跨进最终可执行文件链接期才需要解析,因此作为 link 指令透传给下游。
    if os == "windows" {
        println!("cargo:rustc-link-lib=advapi32");
    }

    // ------------------------------------------------------------------
    // 2. 用 cc crate 编译 C shim(capi.cpp),以独立静态库形式提供
    //    C ABI 符号。最终由 Rust 侧链接 engine_runtime 及其依赖库。
    // ------------------------------------------------------------------
    let mut cpp = cc::Build::new();
    cpp.cpp(true)
        .file(manifest_dir.join("capi.cpp"))
        .include(src_dir.join("include"))
        .include(src_dir.join("external/ggml/include"))
        .include(src_dir.join("external/sentencepiece/src"))
        .include(src_dir.join("external/llama_tokenizer"))
        .include(src_dir.join("external/cJSON"))
        .include(src_dir.join("external/libyaml/include"))
        .include(build_dir.join("generated"))
        .pic(true);
    if os == "windows" {
        // MSVC 使用 /std:c++17 语法;同时开启 /utf-8(capi.cpp 含中文注释)。
        cpp.flag("/std:c++17").flag("/utf-8").flag("/EHsc");
    } else {
        cpp.flag_if_supported("-std=c++17");
    }
    if !cfg!(feature = "openmp") {
        cpp.flag_if_supported("-fno-openmp");
    }
    cpp.compile("audio_cpp_capi");

    // ------------------------------------------------------------------
    // 3. 用 bindgen 为 C shim 生成 Rust 绑定。
    // ------------------------------------------------------------------
    let mut bindings_builder = bindgen::Builder::default()
        .header(manifest_dir.join("capi.h").to_str().unwrap())
        .allowlist_function("audiocpp_.*")
        .allowlist_type("audiocpp_.*")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .derive_partialeq(true);

    // MSVC 目标下,把编译器(cc crate 探测到的)INCLUDE 环境变量透传给
    // bindgen 的 clang,否则标准头文件无法解析。
    if os == "windows" {
        let cc = cc::Build::new();
        let compiler = cc.try_get_compiler().expect("探测 C 编译器失败");
        if let Some((_, include_env)) = compiler
            .env()
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case("INCLUDE"))
        {
            for inc in include_env.to_string_lossy().split(';').filter(|s| !s.is_empty()) {
                bindings_builder = bindings_builder.clang_arg("-isystem").clang_arg(inc);
            }
        }
        let target = env::var("TARGET").unwrap_or_default();
        bindings_builder = bindings_builder
            .clang_arg(format!("--target={}", target))
            .clang_arg("-fms-compatibility")
            .clang_arg("-fms-extensions");
    }

    let bindings = bindings_builder
        .generate()
        .expect("生成 capi 绑定失败");
    bindings
        .write_to_file(out_dir.join("bindings.rs"))
        .expect("写入 bindings.rs 失败");
}