From 15f1fc563ca190d92a760773c51f2ed6de3dbd40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 20:58:58 +0000 Subject: [PATCH 1/3] fix(transcribe): support I32/S32_LE + multi-channel USB mic arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: after the three-stage probe landed, a USB mic array on a Pi still failed with "device accepted no usable capture format". XMOS-based arrays (ReSpeaker et al.) almost always expose audio as S32_LE (cpal `I32`) and are multi-channel (4/6/8 mics) — neither of which the probe handled, so a perfectly good device looked unusable: - stage 2 filtered the array's I32 configs out as "not decodable", - stage 3 only ever tried I16/F32/U16 at 1–2 channels. Changes: - Decode I32 (S32_LE) on every live-capture path: add it to the decodable set, the probe's try-open list, and the format match in BOTH run_session (local ASR) and run_capture (mesh-host sender, added in #271) — normalized by the full i32 range, which also suits 24-bit-left-justified data. - Brute-force the 4/6/8-channel layouts arrays use, not just mono/ stereo. Downmix already collapses any channel count to mono. - Log the chosen device name and every config the backend advertises (`[transcribe] supported: …`), and surface enumeration errors, so a device that still won't open (e.g. 24-bit-only S24_3LE, which cpal can't represent) is diagnosable from the logs instead of a dead end. https://claude.ai/code/session_01UNuFAHUiYkEow3sXB5s3f1 --- src-tauri/src/transcribe.rs | 150 ++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/transcribe.rs b/src-tauri/src/transcribe.rs index bd0d223..a5578c9 100644 --- a/src-tauri/src/transcribe.rs +++ b/src-tauri/src/transcribe.rs @@ -132,9 +132,19 @@ fn stream_err_fn( fn probe_input_config(device: &cpal::Device) -> Result<(cpal::SampleFormat, cpal::StreamConfig)> { use cpal::SampleFormat; - // The three sample formats the capture callback knows how to decode. - let decodable = - |f: SampleFormat| matches!(f, SampleFormat::F32 | SampleFormat::I16 | SampleFormat::U16); + let dev_name = device.name().unwrap_or_else(|_| "".to_string()); + eprintln!("[transcribe] probing capture config for input device {dev_name:?}"); + + // Sample formats the capture callback knows how to decode. I32 covers + // the S32_LE that XMOS-based USB mic arrays (ReSpeaker et al.) almost + // always expose — and frequently *only* expose, which is exactly what + // makes a working array look like "no usable capture format". + let decodable = |f: SampleFormat| { + matches!( + f, + SampleFormat::F32 | SampleFormat::I16 | SampleFormat::U16 | SampleFormat::I32 + ) + }; // 1. Happy path. match device.default_input_config() { @@ -155,39 +165,65 @@ fn probe_input_config(device: &cpal::Device) -> Result<(cpal::SampleFormat, cpal // 2. Enumerate whatever the backend admits and take the best decodable // range. Prefer a range that can reach TARGET_SR (downsampling // beats upsampling) and the fewest channels (less to downmix). - if let Ok(ranges) = device.supported_input_configs() { - let mut best: Option = None; - for r in ranges.filter(|r| decodable(r.sample_format())) { - best = Some(match best { - Some(cur) - if config_score(cur.channels(), cur.max_sample_rate().0) - >= config_score(r.channels(), r.max_sample_rate().0) => - { - cur - } - _ => r, - }); - } - if let Some(r) = best { - // Clamp to a sane rate inside the range: TARGET_SR if it fits - // (then no resampling at all), otherwise the nearest end. - let rate = TARGET_SR.clamp(r.min_sample_rate().0, r.max_sample_rate().0); - let cfg = r.with_sample_rate(cpal::SampleRate(rate)); - let format = cfg.sample_format(); - eprintln!( - "[transcribe] using enumerated input config: sr={rate} ch={} format={format:?}", - cfg.channels() - ); - return Ok((format, cfg.into())); + match device.supported_input_configs() { + Ok(ranges) => { + // Log *every* advertised config first — the single most useful + // breadcrumb if the device still won't open (e.g. it only + // offers a format cpal can't represent at all, like S24_3LE). + let all: Vec = ranges.collect(); + if all.is_empty() { + eprintln!("[transcribe] supported_input_configs: device advertised none"); + } + for r in &all { + eprintln!( + "[transcribe] supported: format={:?} ch={} rate={}..{}", + r.sample_format(), + r.channels(), + r.min_sample_rate().0, + r.max_sample_rate().0 + ); + } + let mut best: Option = None; + for r in all.into_iter().filter(|r| decodable(r.sample_format())) { + best = Some(match best { + Some(cur) + if config_score(cur.channels(), cur.max_sample_rate().0) + >= config_score(r.channels(), r.max_sample_rate().0) => + { + cur + } + _ => r, + }); + } + if let Some(r) = best { + // Clamp to a sane rate inside the range: TARGET_SR if it fits + // (then no resampling at all), otherwise the nearest end. + let rate = TARGET_SR.clamp(r.min_sample_rate().0, r.max_sample_rate().0); + let cfg = r.with_sample_rate(cpal::SampleRate(rate)); + let format = cfg.sample_format(); + eprintln!( + "[transcribe] using enumerated input config: sr={rate} ch={} format={format:?}", + cfg.channels() + ); + return Ok((format, cfg.into())); + } } + Err(e) => eprintln!("[transcribe] supported_input_configs failed: {e}"), } // 3. Brute force: try to open the device with concrete configs, // ordered by ALSA compatibility — S16_LE mono is the most // universally accepted capture format on Pi-class hardware. - let formats = [SampleFormat::I16, SampleFormat::F32, SampleFormat::U16]; + let formats = [ + SampleFormat::I16, + SampleFormat::I32, + SampleFormat::F32, + SampleFormat::U16, + ]; let rates = [TARGET_SR, 48_000, 44_100, 32_000, 8_000]; - let channels_opts = [1u16, 2]; + // Include the 4/6/8-channel layouts USB mic arrays use — many reject a + // mono or stereo request outright and only accept their native count. + let channels_opts = [1u16, 2, 4, 6, 8]; for &format in &formats { for &rate in &rates { for &channels in &channels_opts { @@ -207,7 +243,7 @@ fn probe_input_config(device: &cpal::Device) -> Result<(cpal::SampleFormat, cpal } Err(anyhow!( - "input config: device accepted no usable capture format (tried the default config plus {} fallback combinations). The microphone may be in use by another app, output-only, or unplugged.", + "input config: device {dev_name:?} accepted no usable capture format (tried the default config plus {} fallback combinations). Check the `[transcribe] supported:` lines logged above for what it advertises — if its only formats are 24-bit (e.g. S24_3LE) we can't decode them yet; otherwise the mic may be in use by another app, output-only, or unplugged.", formats.len() * rates.len() * channels_opts.len() )) } @@ -239,6 +275,9 @@ fn try_open_input( cpal::SampleFormat::U16 => { device.build_input_stream(cfg, |_: &[u16], _| {}, err_fn, None)? } + cpal::SampleFormat::I32 => { + device.build_input_stream(cfg, |_: &[i32], _| {}, err_fn, None)? + } _ => return Err(cpal::BuildStreamError::StreamConfigNotSupported), }; drop(stream); @@ -1536,6 +1575,25 @@ fn run_capture( None, )? } + cpal::SampleFormat::I32 => { + let tx = tx.clone(); + let cancel = cancel.clone(); + let paused = paused.clone(); + device.build_input_stream( + &stream_cfg, + move |data: &[i32], _| { + if cancel.load(Ordering::Relaxed) || paused.load(Ordering::Relaxed) { + return; + } + // S32_LE from XMOS USB mic arrays; normalize by the + // full i32 range (also right for 24-bit-left-justified). + let f: Vec = data.iter().map(|&s| s as f32 / i32::MAX as f32).collect(); + let _ = tx.try_send(downmix_f32(&f, channels)); + }, + stream_err_fn(stream_err.clone()), + None, + )? + } other => return Err(anyhow!("unsupported sample format {other:?}")), }; stream.play()?; @@ -2042,6 +2100,38 @@ fn run_session( None, )? } + cpal::SampleFormat::I32 => { + let tx = tx.clone(); + let cancel = cancel_audio.clone(); + let draining = draining_audio.clone(); + let recorder = recorder_cb.clone(); + device.build_input_stream( + &stream_cfg, + { + let paused = paused.clone(); + move |data: &[i32], _| { + if cancel.load(Ordering::Relaxed) + || paused.load(Ordering::Relaxed) + || draining.load(Ordering::Relaxed) + { + return; + } + // S32_LE — what XMOS USB mic arrays emit. Normalize + // by the full i32 range; 24-bit-left-justified data + // (low byte zero) lands at the right amplitude too. + let f: Vec = + data.iter().map(|&s| s as f32 / i32::MAX as f32).collect(); + let mono = downmix_f32(&f, channels); + if let Some(rec) = &recorder { + rec.write(mono.clone()); + } + let _ = tx.try_send(mono); + } + }, + stream_err_fn(stream_err.clone()), + None, + )? + } other => return Err(anyhow!("unsupported sample format: {other:?}")), }; stream.play()?; From 8c13284507015e11375742206ae5cc3e397fe52b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 21:19:22 +0000 Subject: [PATCH 2/3] fix(windows): bundle the MSVC runtime so onnxruntime loads without VC++ redist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onnxruntime.dll (fetched on first run) links the MSVC runtime (vcruntime140 / msvcp140). On a consumer Windows PC that doesn't have the Visual C++ redistributable installed, loading it fails or hangs — what ort_setup surfaces as the "install the VC++ runtime" error. A compiled app shouldn't punt that to the user. Ship the redistributable CRT with the app (Microsoft permits app-local redistribution) and make the loader find it: - Portable zip: copy vcruntime140.dll / vcruntime140_1.dll / msvcp140.dll next to myownllm.exe. The exe's own directory is always searched for a dependency, so onnxruntime.dll resolves the CRT with nothing else needed. - Installer: stage the same DLLs and bundle them as Windows-only Tauri resources (tauri.windows.conf.json, preserving the espeak data). At load time ort_setup prepends the dir holding the bundled CRT to the DLL search path (SetDllDirectoryW) so the resource-dir copy resolves even though onnxruntime.dll lives outside the exe dir. Both source the DLLs from the runner's System32 at release time, so no binaries are checked in. If no bundled CRT is found we fall back to the existing "install the VC++ runtime" guidance — nothing regresses for users who already have it. The directory-search list is a pure, unit-tested helper; the SetDllDirectoryW call is the only Windows-gated piece. https://claude.ai/code/session_01UNuFAHUiYkEow3sXB5s3f1 --- .github/workflows/release.yml | 30 +++++++++ .gitignore | 4 ++ src-tauri/src/ort_setup.rs | 102 ++++++++++++++++++++++++++++++ src-tauri/tauri.windows.conf.json | 9 +++ 4 files changed, 145 insertions(+) create mode 100644 src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c95764..11e5ce5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,26 @@ jobs: echo "dist/ OK:" ls -la dist/ dist/assets/ + # onnxruntime.dll (fetched on first run) links the MSVC runtime + # (vcruntime140 / msvcp140). Stage the redistributable CRT DLLs from + # the runner so the Windows installer ships them — see the + # `vcredist/*` resource in tauri.windows.conf.json. Consumer PCs + # without the VC++ redistributable then load the speech engine + # instead of erroring. Best-effort: a missing DLL warns, not fails. + - name: Stage MSVC runtime for the Windows bundle + if: runner.os == 'Windows' + shell: bash + run: | + mkdir -p src-tauri/vcredist + for dll in vcruntime140.dll vcruntime140_1.dll msvcp140.dll; do + if cp "/c/Windows/System32/$dll" "src-tauri/vcredist/$dll"; then + echo "staged $dll" + else + echo "::warning::couldn't stage $dll from System32" + fi + done + ls -la src-tauri/vcredist || true + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -202,6 +222,16 @@ jobs: if [[ "$have_espeak" == 1 ]]; then cp "$ESPEAK" "dist-bin/espeak-ng.exe"; payload+=(espeak-ng.exe) fi + # Ship the MSVC CRT next to the exe so the fetched onnxruntime.dll + # loads on a PC without the VC++ redistributable — the exe's own + # directory is always searched for a DLL's dependencies. + for dll in vcruntime140.dll vcruntime140_1.dll msvcp140.dll; do + if cp "/c/Windows/System32/$dll" "dist-bin/$dll"; then + payload+=("$dll") + else + echo "::warning::couldn't stage $dll for the portable zip" + fi + done # chdir before hashing so the `.sha256` sidecar records just the # basename — matching how the GitHub asset is uploaded. Hashing # `dist-bin/` from the repo root would bake `dist-bin/` diff --git a/.gitignore b/.gitignore index dfd9949..c87ee53 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ src-tauri/gen/schemas/*-schema.json # bundler can ship it as a sidecar (see tauri.conf.json::externalBin). # Build artefact; the pinned source rev lives in `.myownmesh-rev`. src-tauri/binaries/ +# release.yml stages the redistributable MSVC CRT DLLs here on Windows +# (from the runner's System32) so the bundler ships them as resources; +# they're build artefacts pulled in at release time, never source. +src-tauri/vcredist/ .env .env.local .DS_Store diff --git a/src-tauri/src/ort_setup.rs b/src-tauri/src/ort_setup.rs index d344972..dca5d3c 100644 --- a/src-tauri/src/ort_setup.rs +++ b/src-tauri/src/ort_setup.rs @@ -261,6 +261,13 @@ fn run_init() -> (OrtStatus, String) { "[ort_setup] loading onnxruntime from {}…", existing.display() ); + // On Windows, onnxruntime.dll links the MSVC runtime + // (vcruntime140 / msvcp140). Make any CRT we ship with the app + // discoverable so the load doesn't fail/hang on a consumer PC that + // lacks the VC++ redistributable — the failure this function would + // otherwise report below. + #[cfg(windows)] + prepare_msvc_runtime(existing.parent().unwrap_or_else(|| Path::new("."))); let load_path = existing.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -318,6 +325,81 @@ fn run_init() -> (OrtStatus, String) { } } +/// Candidate directories that may hold the MSVC CRT DLLs we bundle with +/// the app (`vcruntime140.dll` & friends), most-specific first. The +/// portable zip ships them next to the exe; the Windows installer ships +/// them as Tauri resources (a `vcredist/` subdir). Kept pure so the +/// directory list is unit-testable on any OS — only the search-path +/// mutation in [`prepare_msvc_runtime`] is Windows-specific. +/// +/// Gated to where it's actually referenced (the Windows caller or the +/// tests) so a Linux release build doesn't flag it dead. +#[cfg(any(windows, test))] +fn msvc_runtime_search_dirs(runtime_dir: &Path, exe_dir: Option<&Path>) -> Vec { + let mut dirs = vec![runtime_dir.to_path_buf()]; + if let Some(d) = exe_dir { + dirs.push(d.to_path_buf()); // portable: next to the exe + dirs.push(d.join("resources")); // tauri resource root (NSIS) + dirs.push(d.join("vcredist")); // resources mapped to vcredist/ + dirs.push(d.join("resources").join("vcredist")); + } + dirs +} + +/// Windows: ensure onnxruntime.dll's MSVC-runtime dependency resolves +/// from a CRT we ship with the app, so a PC without the VC++ +/// redistributable can still load the speech engine. Finds the dir +/// holding the bundled CRT and prepends it to the DLL search path — +/// which also governs dependency resolution for the subsequent +/// `ort::init_from`. A no-op (leaving the existing "install the VC++ +/// runtime" guidance to fire) if we didn't bundle one. +#[cfg(windows)] +fn prepare_msvc_runtime(runtime_dir: &Path) { + const PROBE: &str = "vcruntime140.dll"; + let exe = std::env::current_exe().ok(); + let exe_dir = exe.as_deref().and_then(Path::parent); + for dir in msvc_runtime_search_dirs(runtime_dir, exe_dir) { + if dir.join(PROBE).exists() { + if win::set_dll_directory(&dir) { + eprintln!( + "[ort_setup] bundled MSVC runtime found; added to DLL search path: {}", + dir.display() + ); + } else { + eprintln!("[ort_setup] SetDllDirectory failed for {}", dir.display()); + } + return; + } + } + eprintln!( + "[ort_setup] no bundled MSVC runtime DLLs found near the app; \ + relying on a system-installed VC++ redistributable" + ); +} + +#[cfg(windows)] +mod win { + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + + #[allow(non_snake_case)] + extern "system" { + fn SetDllDirectoryW(path: *const u16) -> i32; + } + + /// Prepend `dir` to the process DLL search path (and thus the + /// dependency search for DLLs loaded afterwards). Returns false on + /// failure. `SetDllDirectoryW` is exported by kernel32, which the + /// MSVC target links by default. + pub fn set_dll_directory(dir: &Path) -> bool { + let mut wide: Vec = dir.as_os_str().encode_wide().collect(); + wide.push(0); + // SAFETY: `wide` is a valid NUL-terminated UTF-16 buffer that + // outlives the call; SetDllDirectoryW only reads from it. + unsafe { SetDllDirectoryW(wide.as_ptr()) != 0 } + } +} + /// Where to look for `libonnxruntime.{dylib,so,dll}`, in priority order. /// `env_override` is the `ORT_DYLIB_PATH` env var the public wrapper reads; /// passing it in (rather than reading the env directly) keeps the unit @@ -505,6 +587,26 @@ mod tests { ); } + #[test] + fn msvc_search_dirs_lists_runtime_and_bundle_locations() { + let rt = Path::new("/home/user/.myownllm/runtime"); + let exe_dir = Path::new("/opt/app"); + let dirs = msvc_runtime_search_dirs(rt, Some(exe_dir)); + // The onnxruntime dir is checked first (DLLs next to the dylib). + assert_eq!(dirs.first().map(|p| p.as_path()), Some(rt)); + // Portable layout: CRT next to the exe. + assert!(dirs.iter().any(|p| p == Path::new("/opt/app"))); + // Installer layouts: a vcredist/ resource dir and the resource root. + assert!(dirs.iter().any(|p| p.ends_with("vcredist"))); + assert!(dirs.iter().any(|p| p.ends_with("resources"))); + } + + #[test] + fn msvc_search_dirs_without_exe_is_just_runtime() { + let rt = Path::new("/tmp/rt"); + assert_eq!(msvc_runtime_search_dirs(rt, None), vec![rt.to_path_buf()]); + } + #[test] fn status_before_init_reports_not_initialized() { let s = status(); diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..74b959d --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": [ + "binaries/espeak-ng-data", + "vcredist/*" + ] + } +} From b1cb9f02e0e0be0ff0f8098c151883dab41c1145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 21:34:21 +0000 Subject: [PATCH 3/3] fix(build): stage MSVC CRT in build.rs so the vcredist resource can't break the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added a `vcredist/*` Tauri resource but only staged the DLLs in release.yml. CI's `check (windows-latest)` job runs `cargo clippy`, which runs build.rs -> tauri_build::build(), which validates `resources` globs at build time — and failed with: glob pattern vcredist/* path not found or didn't match any files The same break would hit `pnpm tauri dev` and any local Windows build, since they all run the bundler's resource check. Stage the CRT during the build instead, mirroring write_espeak_stub: build.rs::stage_vcredist copies vcruntime140 / vcruntime140_1 / msvcp140 from the host's System32 into vcredist/ on every Windows target build, and drops a marker if none were found so the resource dir is never empty. The resource is now the `vcredist` directory (like binaries/espeak-ng-data) rather than a glob, so an empty match can't fail the bundler. The redundant release.yml staging step is removed; the portable-zip copy next to the exe stays. https://claude.ai/code/session_01UNuFAHUiYkEow3sXB5s3f1 --- .github/workflows/release.yml | 24 +++--------- src-tauri/build.rs | 61 +++++++++++++++++++++++++++++++ src-tauri/tauri.windows.conf.json | 2 +- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11e5ce5..783129a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,25 +153,11 @@ jobs: echo "dist/ OK:" ls -la dist/ dist/assets/ - # onnxruntime.dll (fetched on first run) links the MSVC runtime - # (vcruntime140 / msvcp140). Stage the redistributable CRT DLLs from - # the runner so the Windows installer ships them — see the - # `vcredist/*` resource in tauri.windows.conf.json. Consumer PCs - # without the VC++ redistributable then load the speech engine - # instead of erroring. Best-effort: a missing DLL warns, not fails. - - name: Stage MSVC runtime for the Windows bundle - if: runner.os == 'Windows' - shell: bash - run: | - mkdir -p src-tauri/vcredist - for dll in vcruntime140.dll vcruntime140_1.dll msvcp140.dll; do - if cp "/c/Windows/System32/$dll" "src-tauri/vcredist/$dll"; then - echo "staged $dll" - else - echo "::warning::couldn't stage $dll from System32" - fi - done - ls -la src-tauri/vcredist || true + # NOTE: the MSVC CRT for the *installer* bundle is staged by + # build.rs (stage_vcredist) into src-tauri/vcredist/ and shipped via + # tauri.windows.conf.json's `vcredist` resource — so no staging step + # is needed here. The portable .zip gets its own copy next to the + # exe in the "Package portable binary" step below. - uses: tauri-apps/tauri-action@v0 env: diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 789ae73..2ab3a59 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -75,6 +75,19 @@ fn main() { } } + // Windows: stage the MSVC CRT the fetched onnxruntime.dll links + // against (vcruntime140 / msvcp140), so the bundle ships it via + // tauri.windows.conf.json's `vcredist` resource — a PC without the + // VC++ redistributable then still loads the speech engine. Runs for + // every Windows build (CI, `tauri dev`, release) so the bundler's + // resource check always sees a populated dir. + let target_triple = env::var("TARGET").unwrap_or_default(); + if target_triple.contains("windows") { + if let Err(e) = stage_vcredist() { + println!("cargo:warning=could not stage vcredist MSVC runtime: {e:#}"); + } + } + tauri_build::build(); } @@ -139,6 +152,54 @@ fn write_espeak_stub() -> std::io::Result<()> { Ok(()) } +/// Stage the redistributable MSVC runtime DLLs into `vcredist/` so the +/// Windows bundle ships them as a `resources` entry (see +/// tauri.windows.conf.json). The fetched `onnxruntime.dll` links these, +/// so shipping them lets a PC without the Visual C++ redistributable +/// load the speech engine instead of failing. +/// +/// Best-effort: copies whatever the host's System32 holds and, if none +/// were staged (e.g. a cross-build host), drops a marker so the resource +/// dir is non-empty and `tauri_build`'s check still passes — mirroring +/// [`write_espeak_stub`]. With no bundled CRT the runtime simply falls +/// back to a system-installed one. +fn stage_vcredist() -> std::io::Result<()> { + let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let dir = crate_dir.join("vcredist"); + fs::create_dir_all(&dir)?; + + let system32 = env::var("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(r"C:\Windows")) + .join("System32"); + + let mut staged = 0; + for dll in ["vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll"] { + let dst = dir.join(dll); + if dst.exists() { + staged += 1; + continue; + } + let src = system32.join(dll); + if src.exists() { + match fs::copy(&src, &dst) { + Ok(_) => staged += 1, + Err(e) => println!("cargo:warning=couldn't stage {dll} into vcredist/: {e}"), + } + } + } + + if staged == 0 { + // Keep the resource dir non-empty so the bundler's existence + // check passes even when no CRT could be sourced. + fs::write( + dir.join(".vcredist-stub"), + b"MSVC CRT not staged (no System32 source); runtime uses the system VC++ redistributable\n", + )?; + } + Ok(()) +} + /// Build + stage the owned espeak-ng. Resolution order, mirroring the daemon /// sidecar: a prebuilt prefix via `MYOWNLLM_ESPEAK_PREBUILT` (CI builds once /// and points here), else a cached static build from source. diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 74b959d..2e0d4a6 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -3,7 +3,7 @@ "bundle": { "resources": [ "binaries/espeak-ng-data", - "vcredist/*" + "vcredist" ] } }