transcribe-cpp-sys 0.3.1

Native FFI bindings for transcribe.cpp, a C/C++ speech-to-text library built on ggml
Documentation
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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! Build script for `transcribe-cpp-sys`.
//!
//! Source build is the primary path: the `cmake` crate drives the vendored
//! C++ tree with `TRANSCRIBE_INSTALL=ON`, and the link line is reconstructed
//! from NOTHING but the installed `lib/transcribe-link.json` manifest — the
//! same artifact the `link_smoke` CI lane compiles a toy C consumer against.
//! No per-platform link lists are hardcoded here (the whisper-rs drift class
//! this avoids).
//!
//! Prebuilt path: setting TRANSCRIBE_DIR (OPENSSL_DIR-style) to an install
//! prefix produced by `cmake --install` of a TRANSCRIBE_INSTALL=ON build
//! skips the source build entirely and links against that prefix's manifest
//! instead. Cargo features are inert there: the prebuilt already decided its
//! configuration (static/shared, backends), and the manifest records it.
//!
//! Cargo features map directly to CMake options:
//!   `shared`           -> TRANSCRIBE_BUILD_SHARED=ON (default: static)
//!   `dynamic-backends` -> the above + TRANSCRIBE_GGML_BACKEND_DL=ON (+ x86:
//!                         GGML_CPU_ALL_VARIANTS=ON, TRANSCRIBE_X86_CONSERVATIVE=ON)
//!   `metal`            -> TRANSCRIBE_METAL=ON   (Apple targets only; no-op elsewhere)
//!   `vulkan`           -> TRANSCRIBE_VULKAN=ON
//!   `cuda`             -> TRANSCRIBE_CUDA=ON
//!   `rocm`             -> TRANSCRIBE_HIP=ON
//!   `openmp`           -> TRANSCRIBE_USE_OPENMP=ON
//! Official-artifact hygiene flags (OpenMP/BLAS off) are deliberately NOT
//! forced here: a source build is the consumer's build (same philosophy as
//! the Python sdist).
//!
//! Escape hatch: anything else CMake accepts can be passed via the
//! TRANSCRIBE_CMAKE_ARGS (or CMAKE_ARGS) env var — see the passthrough at the
//! end of main(). This is the "no Cargo feature is a hard ceiling" guarantee.
//!
//! Windows: the native build runs through a short NTFS junction to OUT_DIR so
//! a stock machine builds the Vulkan backend from any checkout depth (MAX_PATH).

use std::env;
use std::path::{Path, PathBuf};

fn feature(name: &str) -> bool {
    env::var_os(format!("CARGO_FEATURE_{name}")).is_some()
}

/// Split a CMAKE_ARGS-style string into individual arguments, honoring simple
/// double-quotes so a value containing spaces survives (e.g. `-DFOO="a b"`).
/// Whitespace-separated otherwise; quotes are stripped from the emitted token.
fn split_cmake_args(s: &str) -> Vec<String> {
    let mut args = Vec::new();
    let mut cur = String::new();
    let mut in_quotes = false;
    let mut has_token = false;
    for c in s.chars() {
        match c {
            '"' => {
                in_quotes = !in_quotes;
                has_token = true;
            }
            c if c.is_whitespace() && !in_quotes => {
                if has_token {
                    args.push(std::mem::take(&mut cur));
                    has_token = false;
                }
            }
            c => {
                cur.push(c);
                has_token = true;
            }
        }
    }
    if has_token {
        args.push(cur);
    }
    args
}

fn main() {
    // CARGO_MANIFEST_DIR for this crate is the repo root (the sys crate's
    // manifest lives there so the tarball can carry the whole C++ tree).
    let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());

    for p in [
        "CMakeLists.txt",
        "CMakePresets.json",
        "include",
        "src",
        "ggml",
        "cmake",
        "bindings/rust/sys/build.rs",
        "bindings/rust/sys/src",
    ] {
        println!("cargo:rerun-if-changed={}", root.join(p).display());
    }

    // Prebuilt path: TRANSCRIBE_DIR points at an existing install prefix (a
    // `cmake --install` tree from a TRANSCRIBE_INSTALL=ON configure). Skip the
    // source build and emit the link line from that prefix's manifest; the
    // manifest records the install's own posture (static/shared, backends), so
    // the Cargo features below never apply here.
    println!("cargo:rerun-if-env-changed=TRANSCRIBE_DIR");
    if let Some(dir) = env::var_os("TRANSCRIBE_DIR") {
        let prefix = PathBuf::from(dir);
        let manifest = find_manifest(&prefix).unwrap_or_else(|| {
            panic!(
                "TRANSCRIBE_DIR is set but no lib/transcribe-link.json (or lib64/) exists \
                 under {}. It must point at an install prefix of this library, produced by \
                 `cmake -B build -DTRANSCRIBE_INSTALL=ON && cmake --build build && \
                 cmake --install build --prefix <dir>`. Unset TRANSCRIBE_DIR to build \
                 the vendored sources instead.",
                prefix.display()
            )
        });
        println!("cargo:rerun-if-changed={}", manifest.display());
        emit_link_lines(&prefix, &manifest);
        return;
    }

    // `dynamic-backends` (loadable backend modules) requires a shared library,
    // so it implies `shared`. The Cargo manifest already encodes that implication
    // (`dynamic-backends = ["shared"]`), but treat it as load-bearing here too.
    let dynamic_backends = feature("DYNAMIC_BACKENDS");
    let shared = feature("SHARED") || dynamic_backends;
    let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
    let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
    let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
    let is_apple = matches!(target_os.as_str(), "macos" | "ios");
    let is_x86 = matches!(target_arch.as_str(), "x86" | "x86_64");

    let mut cfg = cmake::Config::new(&root);
    cfg.profile("Release") // a transcription library always wants an optimized native core
        .define("TRANSCRIBE_INSTALL", "ON")
        .define("TRANSCRIBE_BUILD_TESTS", "OFF")
        .define("TRANSCRIBE_BUILD_EXAMPLES", "OFF")
        .define("TRANSCRIBE_BUILD_TOOLS", "OFF")
        .define("TRANSCRIBE_BUILD_SHARED", if shared { "ON" } else { "OFF" });

    // Force optimization on MSVC. `.profile("Release")` only selects the *config*
    // (and CRT) of the Visual Studio multi-config generator — it does NOT
    // guarantee optimization. cmake-rs strips `/O*` from the cc-derived flags
    // ("let cmake deal with optimization") and then, for the VS generator,
    // overwrites CMAKE_<LANG>_FLAGS_RELEASE with those stripped flags — dropping
    // CMake's default `/O2 /Ob2 /DNDEBUG`. The "Release" build then compiles ggml
    // UNOPTIMIZED with assertions live: ~7x slower on CPU (realtime collapses to
    // ~1x) and a pred-heavy ~3x slower decode on Vulkan. Flags passed via
    // cflag/cxxflag are injected verbatim (un-stripped) into that same
    // CMAKE_<LANG>_FLAGS_RELEASE — the var the VS generator actually respects — so
    // re-adding them here restores optimization. (Off-MSVC the GNU/Clang lanes get
    // -O from the profile and CMake's Release init flags aren't stripped.)
    if target_env == "msvc" {
        for flag in ["/O2", "/Ob2", "/DNDEBUG"] {
            cfg.cflag(flag);
            cfg.cxxflag(flag);
        }
    }

    // Dynamic backend modules: each compute backend becomes a loadable module
    // next to libtranscribe, picked at runtime by transcribe_init_backends().
    // The root CMakeLists validates BACKEND_DL => SHARED and force-sets
    // GGML_NATIVE=OFF, so this just flips the knobs. On x86, fan the CPU backend
    // out into one module per ISA tier (runtime feature scoring) over the
    // SIGILL-safe x86 floor — the same posture the Linux/Windows cpu-vulkan
    // wheel lane ships. ALL_VARIANTS is an x86 concept; on arm a DL build is a
    // single portable CPU module.
    if dynamic_backends {
        cfg.define("TRANSCRIBE_GGML_BACKEND_DL", "ON");
        if is_x86 {
            cfg.define("GGML_CPU_ALL_VARIANTS", "ON");
            cfg.define("TRANSCRIBE_X86_CONSERVATIVE", "ON");
        }
    }

    // Metal: on Apple, set TRANSCRIBE_METAL EXPLICITLY to track the `metal`
    // feature. CMake defaults TRANSCRIBE_METAL ON on Apple Silicon, so without an
    // explicit OFF a `--no-default-features` (metal off) build would still enable
    // Metal — breaking Cargo.toml's "pure CPU build on macOS is
    // default-features = false" contract. Off Apple the feature is a no-op (CMake
    // already defaults it OFF).
    if is_apple {
        if feature("METAL") {
            cfg.define("TRANSCRIBE_METAL", "ON");
            // Self-contained installed tree: embed the metallib instead of a
            // sidecar default.metallib next to the lib (matches the shipped macOS
            // wheel posture; what the shared-infra link-smoke uses).
            cfg.define("GGML_METAL_EMBED_LIBRARY", "ON");
        } else {
            cfg.define("TRANSCRIBE_METAL", "OFF");
        }
    }
    if feature("VULKAN") {
        cfg.define("TRANSCRIBE_VULKAN", "ON");
    }
    if feature("CUDA") {
        cfg.define("TRANSCRIBE_CUDA", "ON");
    }
    if feature("ROCM") {
        cfg.define("TRANSCRIBE_HIP", "ON");
    }
    // Keep OpenMP OFF unless explicitly opted in. TRANSCRIBE_USE_OPENMP already
    // defaults OFF in CMake (the native ggml threadpool is the default path); we
    // set it explicitly here so `--features openmp` is the single switch. We keep
    // it OFF by default because OpenMP's `-fopenmp` shows up only as a manifest
    // link_flag → a `cargo:rustc-link-arg` that does NOT propagate to downstream
    // binaries, so a static consumer link would fail with undefined GOMP_*/omp_*
    // symbols. A self-contained static build is the default; `--features openmp`
    // opts in (and then owns providing the OpenMP runtime).
    cfg.define(
        "TRANSCRIBE_USE_OPENMP",
        if feature("OPENMP") { "ON" } else { "OFF" },
    );
    // Windows no longer needs ggml's OpenMP. ggml's native CPU threadpool barrier
    // used to deadlock under MSVC (its spin `relax` compiled to a no-op, starving
    // an un-arrived worker), so OpenMP was the only multi-threaded CPU path there.
    // That barrier is fixed upstream (ggml-cpu.c: YieldProcessor relax + a
    // bounded-spin ggml_thread_yield fallback), so the native pool is correct on
    // MSVC — and avoids the vcomp pool that crashes at teardown when a host
    // dlopen/dlcloses this lib. We therefore leave GGML_OPENMP at its
    // CMakeLists-forced OFF on every platform; `--features openmp` is the only
    // opt-in. See the "OpenMP — CENTRAL POLICY" block in the root CMakeLists.txt.

    // Escape hatch: forward arbitrary configure args so the curated features are
    // never a hard ceiling. Anything CMake accepts (-DGGML_*, a -DTRANSCRIBE_*
    // the features don't cover, a toolchain define, ...) can be passed via
    // TRANSCRIBE_CMAKE_ARGS / CMAKE_ARGS. Fed AFTER the feature-derived defines
    // so a user -D wins on the first configure. Unsupported/untested by design —
    // it exists so a consumer is never blocked. The link line is still
    // reconstructed from the regenerated manifest, so whatever this turns on is
    // linked correctly with no per-flag knowledge here.
    for var in ["TRANSCRIBE_CMAKE_ARGS", "CMAKE_ARGS"] {
        println!("cargo:rerun-if-env-changed={var}");
        if let Ok(extra) = env::var(var) {
            for arg in split_cmake_args(&extra) {
                cfg.configure_arg(arg);
            }
        }
    }

    // Windows: build through a short junction to OUT_DIR so the native build
    // stays under MAX_PATH on stock machines (see windows_short_out_dir).
    let short = windows_short_out_dir();
    if let Some(short) = &short {
        cfg.out_dir(short);
    }

    // Builds + installs into OUT_DIR; the returned path IS the install prefix.
    let prefix = cfg.build();
    // Emit downstream paths via the durable OUT_DIR, not the junction — cargo
    // caches them across builds, and the junction may be deleted between runs.
    let prefix = if short.is_some() {
        PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"))
    } else {
        prefix
    };

    let manifest = find_manifest(&prefix)
        .unwrap_or_else(|| panic!("transcribe-link.json not found under {}", prefix.display()));
    emit_link_lines(&prefix, &manifest);
}

/// Windows MAX_PATH mitigation: a short NTFS junction (`%LOCALAPPDATA%\tcs\<hash>`,
/// no admin needed) resolving to OUT_DIR. The Vulkan ExternalProject nests ~185
/// chars past OUT_DIR, and MSBuild's FileTracker ignores LongPathsEnabled (FTK1011),
/// so the build root itself must be short. None = build in OUT_DIR (non-Windows,
/// or best-effort failure). Idempotent; hash-named per OUT_DIR so checkouts don't collide.
fn windows_short_out_dir() -> Option<PathBuf> {
    if !cfg!(windows) {
        return None;
    }
    // Backslash-normalize: mklink rejects forward slashes (MSYS-style CARGO_TARGET_DIR).
    let out_dir = PathBuf::from(env::var("OUT_DIR").ok()?.replace('/', "\\"));
    let Some(base) = env::var_os("LOCALAPPDATA")
        .or_else(|| env::var_os("TEMP"))
        .map(PathBuf::from)
    else {
        println!(
            "cargo:warning=transcribe-cpp-sys: neither LOCALAPPDATA nor TEMP is set; \
             building in OUT_DIR (may exceed Windows MAX_PATH in deep checkouts)"
        );
        return None;
    };
    let base = base.join("tcs");

    let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a: stable across rustc versions
    for b in out_dir.to_string_lossy().bytes() {
        hash ^= u64::from(b);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    let link = base.join(format!("{hash:016x}"));

    warn_fallback(
        std::fs::create_dir_all(&out_dir),
        "create junction target",
        &out_dir,
    )?;
    // symlink_metadata (not exists()) so a dangling junction is detected and reclaimed.
    if std::fs::symlink_metadata(&link).is_ok() {
        match (
            std::fs::canonicalize(&link),
            std::fs::canonicalize(&out_dir),
        ) {
            (Ok(a), Ok(b)) if a == b => return Some(link),
            // remove_dir fails if something non-junction squats here (e.g. a
            // backup tool materialized it as a real tree); the warning names
            // the path so the user knows what to delete.
            _ => warn_fallback(std::fs::remove_dir(&link), "remove stale junction", &link)?,
        }
    }
    warn_fallback(
        std::fs::create_dir_all(&base),
        "create junction parent",
        &base,
    )?;

    // No std API creates junctions; mklink /J needs no extra deps.
    let output = std::process::Command::new("cmd")
        .arg("/C")
        .arg("mklink")
        .arg("/J")
        .arg(&link)
        .arg(&out_dir)
        .output();
    let created = output.as_ref().map(|o| o.status.success()).unwrap_or(false);
    let verified = created
        && matches!(
            (std::fs::canonicalize(&link), std::fs::canonicalize(&out_dir)),
            (Ok(a), Ok(b)) if a == b
        );
    if !verified {
        // Best-effort: fall back to building in the deep OUT_DIR.
        let detail = output
            .map(|o| {
                String::from_utf8_lossy(if o.stderr.is_empty() {
                    &o.stdout
                } else {
                    &o.stderr
                })
                .trim()
                .to_string()
            })
            .unwrap_or_else(|e| e.to_string());
        println!(
            "cargo:warning=transcribe-cpp-sys: could not create short build junction {} -> {} ({detail}); \
             building in OUT_DIR (may exceed Windows MAX_PATH in deep checkouts)",
            link.display(),
            out_dir.display()
        );
        return None;
    }
    Some(link)
}

/// Best-effort junction setup step: on failure, warn like the mklink branch
/// and bail to the deep-OUT_DIR fallback via `?`. Cargo hides build-script
/// warnings for registry crates unless the build fails — so this is silent on
/// success and visible exactly when a deep-path build dies of MAX_PATH.
fn warn_fallback<T, E: std::fmt::Display>(
    res: Result<T, E>,
    action: &str,
    path: &Path,
) -> Option<T> {
    match res {
        Ok(v) => Some(v),
        Err(e) => {
            println!(
                "cargo:warning=transcribe-cpp-sys: could not {action} {} ({e}); \
                 building in OUT_DIR (may exceed Windows MAX_PATH in deep checkouts)",
                path.display()
            );
            None
        }
    }
}

/// GNUInstallDirs picks `lib` or `lib64`; find the manifest under either.
fn find_manifest(prefix: &Path) -> Option<PathBuf> {
    for libdir in ["lib", "lib64"] {
        let p = prefix.join(libdir).join("transcribe-link.json");
        if p.is_file() {
            return Some(p);
        }
    }
    None
}

/// Convert a conventional absolute Unix library filename into the Cargo native
/// library kind/name pair. `rustc-link-lib` propagates through dependent Rust
/// crates; a raw `rustc-link-arg=/path/to/lib` does not.
fn cargo_library_name(path: &Path) -> Option<(&'static str, String)> {
    let file = path.file_name()?.to_str()?;
    let file = file.strip_prefix("lib")?;

    if let Some(name) = file.strip_suffix(".dll.a") {
        return Some(("dylib", name.to_owned()));
    }
    if let Some(name) = file.strip_suffix(".a") {
        return Some(("static", name.to_owned()));
    }
    for suffix in [".so", ".dylib", ".tbd"] {
        if let Some(name) = file.strip_suffix(suffix) {
            return Some(("dylib", name.to_owned()));
        }
    }
    None
}

fn emit_link_lines(prefix: &Path, manifest_path: &Path) {
    let text = std::fs::read_to_string(manifest_path).expect("read transcribe-link.json");
    let json: serde_json::Value = serde_json::from_str(&text).expect("parse transcribe-link.json");

    let strs = |key: &str| -> Vec<String> {
        json[key]
            .as_array()
            .map(|a| {
                a.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default()
    };

    let shared = json["shared"].as_bool().unwrap_or(false);
    let lib_dir = prefix.join(json["lib_dir"].as_str().unwrap_or("lib"));
    println!("cargo:rustc-link-search=native={}", lib_dir.display());

    // Archives (static) or the single shared lib. The manifest order is
    // single-pass-GNU-ld safe (each archive's undefined refs resolve in a
    // later one: transcribe -> ggml -> backends -> ggml-base).
    let kind = if shared { "dylib" } else { "static" };
    for name in strs("libraries") {
        println!("cargo:rustc-link-lib={kind}={name}");
    }

    // Absolute library paths (e.g. ROCm SDK libraries, compiler-rt, or a
    // find_package(BLAS) result). Express conventional library files as
    // search-dir + rustc-link-lib rather than a raw rustc-link-arg: native
    // library directives propagate from this -sys crate to final downstream
    // binaries, while link arguments only affect this crate's own artifacts.
    // Keep the exact-path fallback for an unusual linker input that cannot be
    // represented as a conventional native library.
    for path in strs("library_paths") {
        let path = Path::new(&path);
        if let (Some(parent), Some((kind, name))) = (path.parent(), cargo_library_name(path)) {
            println!("cargo:rustc-link-search=native={}", parent.display());
            println!("cargo:rustc-link-lib={kind}={name}");
        } else {
            println!("cargo:rustc-link-arg={}", path.display());
        }
    }
    // System libraries the C++/backend archives drag in.
    for name in strs("system_libs") {
        println!("cargo:rustc-link-lib=dylib={name}");
    }
    // Apple frameworks (Metal/Foundation/Accelerate...).
    for name in strs("frameworks") {
        println!("cargo:rustc-link-lib=framework={name}");
    }
    // Extra link flags (e.g. -fopenmp).
    for flag in strs("link_flags") {
        println!("cargo:rustc-link-arg={flag}");
    }
    // Shared posture: the installed libs carry $ORIGIN/@loader_path rpaths, but
    // the consumer binary still needs to find lib_dir itself. Unix uses an
    // rpath; Windows has no rpath (the DLL resolves via PATH / the exe dir), so
    // nothing is emitted there. NOTE: rustc-link-arg does NOT propagate to
    // downstream crates, so this rpath only reaches THIS crate's own artifacts
    // (the -sys smoke tests). The safe crate re-emits it for its tests/examples
    // — see bindings/rust/transcribe-cpp/build.rs.
    let is_windows = env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows");
    if shared && !is_windows {
        println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
    }
    // Windows has no rpath: a shared-posture binary resolves transcribe.dll +
    // the ggml DLLs from its OWN directory. Stage the installed DLLs next to
    // every artifact cargo produces so tests/examples/bins run in place.
    if shared && is_windows {
        stage_windows_dlls(prefix);
    }

    // Metadata for the safe crate's build script (DEP_TRANSCRIBE_* because this
    // crate sets `links = "transcribe"`). The wrapper re-emits these one more hop
    // as DEP_TRANSCRIBE_CPP_* for downstream packaging — see its build.rs.
    println!(
        "cargo:include_dir={}",
        prefix
            .join(json["include_dir"].as_str().unwrap_or("include"))
            .display()
    );
    println!("cargo:lib_dir={}", lib_dir.display());

    // Runtime-artifact dirs for downstream PACKAGING. Only a shared posture
    // produces separate runtime files to ship next to a consumer's executable;
    // a static build bakes everything into the consumer binary, so these stay
    // unset and the consumer reads their absence as "nothing to bundle".
    if shared {
        let bin_dir = prefix.join("bin");
        // The ONE directory a consumer copies into their installer. Windows keeps
        // the runtime DLLs in bin/; on Unix the .so/.dylib live in lib_dir. A
        // single value so the consumer needs no per-OS cfg — `bin_dir` alone
        // would be too Windows-specific.
        let runtime_dir = if is_windows { &bin_dir } else { &lib_dir };
        println!("cargo:runtime_dir={}", runtime_dir.display());
        println!("cargo:bin_dir={}", bin_dir.display());
        // dynamic-backends: the loadable compute modules (a ggml backend subdir,
        // or bin/ on Windows). The manifest already records the install-relative
        // path; forward it so a consumer bundles the modules too — without them a
        // relocated DL build registers zero compute devices.
        if let Some(rel) = json["module_dir"].as_str() {
            println!("cargo:module_dir={}", prefix.join(rel).display());
        }
    }
}

/// Copy the installed runtime DLLs (`<prefix>/bin/*.dll` — transcribe.dll plus
/// the ggml DLLs) next to every artifact cargo will build, so a shared-posture
/// consumer's tests/examples/bins find them with no rpath (Windows has none)
/// and no PATH fiddling. Windows + `shared` only; a no-op anywhere else.
///
/// Windows resolves a process's DLLs from the EXE's own directory first, and
/// cargo emits tests into `deps/`, examples into `examples/`, and bins into the
/// profile root — so all three get the DLLs. This runs from the -sys build
/// script (it owns the native build), which is always in the dep graph, so it
/// covers the safe crate's artifacts too. Best-effort: copy failures warn
/// rather than fail the build.
fn stage_windows_dlls(prefix: &Path) {
    let bin_dir = prefix.join("bin");
    let dlls: Vec<PathBuf> = match std::fs::read_dir(&bin_dir) {
        Ok(entries) => entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("dll"))
            .collect(),
        Err(_) => Vec::new(),
    };
    if dlls.is_empty() {
        println!(
            "cargo:warning=transcribe-cpp-sys: no DLLs under {} to stage for the shared posture",
            bin_dir.display()
        );
        return;
    }
    // OUT_DIR = <target>/<profile>/build/<crate>-<hash>/out; the profile root is
    // four ancestors up. tests -> deps/, examples -> examples/, bins -> root.
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    let Some(profile_dir) = out_dir.ancestors().nth(3) else {
        return;
    };
    for sub in ["", "deps", "examples"] {
        let dest = if sub.is_empty() {
            profile_dir.to_path_buf()
        } else {
            profile_dir.join(sub)
        };
        let _ = std::fs::create_dir_all(&dest);
        for dll in &dlls {
            if let Some(name) = dll.file_name() {
                let _ = std::fs::copy(dll, dest.join(name));
            }
        }
    }
}