diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c95764..783129a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,12 @@ jobs: echo "dist/ OK:" ls -la dist/ dist/assets/ + # 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: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -202,6 +208,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/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/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/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()?; diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..2e0d4a6 --- /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" + ] + } +}