From e9567b823a6961b1fb9304532d21437a204d1155 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:27:23 -0500 Subject: [PATCH 01/28] feat(hardware): AccelPath resolver + honest GPU availability (P0) whisper.cpp bakes in one GPU backend at compile time, so a BackendId alone can't say how a vendor is served. Add AccelPath + resolve_accel(_with) as the single source of truth over (compiled features x runtime readiness), and derive BackendInfo.available from it in the DXGI + NPU paths. Closes the gap where a present-but-unsupported GPU was marked available and best() routed to a no-op GPU. Pure resolve_accel_with is unit-tested across all vendor/feature combos. --- src-tauri/src/hardware/mod.rs | 170 +++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/hardware/mod.rs b/src-tauri/src/hardware/mod.rs index f6965fa..7f5ce08 100644 --- a/src-tauri/src/hardware/mod.rs +++ b/src-tauri/src/hardware/mod.rs @@ -29,6 +29,103 @@ fn rank_of(id: BackendId) -> u8 { } } +/// The concrete engine + device an accelerated transcriber will actually use. +/// +/// whisper.cpp bakes in exactly **one** GPU backend at compile time (CUDA *or* +/// Vulkan, never both), so a `BackendId` alone can't say how a given vendor is +/// served — that depends on which Cargo features this binary was built with and +/// which runtimes are present on disk. This resolver is the single source of +/// truth for that, and it's what makes `available` honest: a GPU vendor is only +/// "available" when it resolves to a non-CPU path (before this, DXGI marked an +/// AMD/NVIDIA GPU available even in a build with no accel for it, so `best()` +/// would route to a GPU that silently no-ops back to CPU). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccelPath { + /// whisper.cpp on CPU — the guaranteed fallback for any vendor with no + /// compiled accel path in this build. + WhisperCpu, + /// whisper.cpp offloading to the GPU via its compiled-in Vulkan backend + /// (universal build: NVIDIA/AMD/Intel). + WhisperVulkan, + /// whisper.cpp offloading to the GPU via its compiled-in CUDA backend + /// (NVIDIA-turbo build). + WhisperCuda, + /// Whisper-ONNX on the Intel NPU via ONNX Runtime + OpenVINO EP. + OnnxOpenVino, + /// Whisper-ONNX on a DX12 GPU via ONNX Runtime + DirectML EP — the + /// non-Vulkan GPU path for AMD/Intel (P2). Requires the DirectML-enabled + /// runtime to be present. + OnnxDirectML, +} + +/// Pure resolution logic, factored out of [`resolve_accel`] so every +/// (vendor × compiled-feature × runtime-present) combination is unit-testable +/// without real hardware or a specific feature build. +fn resolve_accel_with( + backend: BackendId, + cuda: bool, + vulkan: bool, + npu_ready: bool, + directml_ready: bool, +) -> AccelPath { + match backend { + BackendId::Cpu => AccelPath::WhisperCpu, + BackendId::Npu => { + if npu_ready { + AccelPath::OnnxOpenVino + } else { + AccelPath::WhisperCpu + } + } + // NVIDIA prefers its native CUDA fast path, then the universal Vulkan + // build, then DirectML, then CPU. + BackendId::Nvidia => { + if cuda { + AccelPath::WhisperCuda + } else if vulkan { + AccelPath::WhisperVulkan + } else if directml_ready { + AccelPath::OnnxDirectML + } else { + AccelPath::WhisperCpu + } + } + // AMD/Intel: Vulkan in the universal build, else DirectML (the + // non-Vulkan path, e.g. inside the CUDA-only turbo variant), else CPU. + BackendId::Amd | BackendId::Intel => { + if vulkan { + AccelPath::WhisperVulkan + } else if directml_ready { + AccelPath::OnnxDirectML + } else { + AccelPath::WhisperCpu + } + } + } +} + +/// Resolves how `backend` is served in *this* binary, reading the compile-time +/// GPU features and on-disk runtime readiness. See [`AccelPath`]. +pub fn resolve_accel(backend: BackendId) -> AccelPath { + resolve_accel_with( + backend, + cfg!(feature = "cuda"), + cfg!(feature = "vulkan"), + cfg!(feature = "npu") && crate::paths::npu_runtime_ready(), + directml_ready(), + ) +} + +/// Whether the DirectML-enabled ONNX runtime is present (P2). Stubbed `false` +/// until the DirectML runtime + transcriber land, so P0 resolution treats it as +/// unavailable everywhere. +fn directml_ready() -> bool { + // ponytail: filled in by P2 (paths::directml_runtime_ready + a real + // OnnxDirectML transcriber). Until then AMD/Intel non-Vulkan builds resolve + // to CPU, which is the honest, pre-P2 state. + false +} + fn cpu_backend() -> BackendInfo { BackendInfo { id: BackendId::Cpu, @@ -61,7 +158,9 @@ pub fn npu_hardware_present() -> bool { /// after the `OnnxNpuTranscriber` path exists. fn npu_backend() -> BackendInfo { let present = npu_hardware_present(); - let ready = present && crate::paths::npu_runtime_ready(); + // Honest availability: the chip must be present AND resolve to a real accel + // path (OpenVINO), which folds in the `npu` feature gate + runtime download. + let available = present && !matches!(resolve_accel(BackendId::Npu), AccelPath::WhisperCpu); BackendInfo { id: BackendId::Npu, name: if present { @@ -69,7 +168,7 @@ fn npu_backend() -> BackendInfo { } else { "NPU".to_string() }, - available: ready, + available, rank: rank_of(BackendId::Npu), vram_mb: None, } @@ -193,6 +292,67 @@ mod tests { let backends = vec![backend(BackendId::Npu, false)]; assert_eq!(pick_best(&backends, None).id, BackendId::Cpu); } + + // --- AccelPath resolution (P0): pure, feature-independent ----------------- + + #[test] + fn cpu_and_npu_ignore_gpu_features() { + // CPU is always CPU. + assert_eq!( + resolve_accel_with(BackendId::Cpu, true, true, true, true), + AccelPath::WhisperCpu + ); + // NPU keys only off its runtime being ready, not any GPU feature. + assert_eq!( + resolve_accel_with(BackendId::Npu, false, false, true, false), + AccelPath::OnnxOpenVino + ); + assert_eq!( + resolve_accel_with(BackendId::Npu, true, true, false, true), + AccelPath::WhisperCpu + ); + } + + #[test] + fn nvidia_prefers_cuda_then_vulkan_then_directml_then_cpu() { + let n = BackendId::Nvidia; + assert_eq!( + resolve_accel_with(n, true, true, false, true), + AccelPath::WhisperCuda // CUDA wins even if Vulkan/DirectML also present + ); + assert_eq!( + resolve_accel_with(n, false, true, false, true), + AccelPath::WhisperVulkan + ); + assert_eq!( + resolve_accel_with(n, false, false, false, true), + AccelPath::OnnxDirectML + ); + assert_eq!( + resolve_accel_with(n, false, false, false, false), + AccelPath::WhisperCpu + ); + } + + #[test] + fn amd_and_intel_take_vulkan_then_directml_then_cpu_and_never_cuda() { + for v in [BackendId::Amd, BackendId::Intel] { + // CUDA is NVIDIA-only: an AMD/Intel GPU in a CUDA-only build must NOT + // claim CUDA — it falls to DirectML (the non-Vulkan path) or CPU. + assert_eq!( + resolve_accel_with(v, true, false, false, true), + AccelPath::OnnxDirectML + ); + assert_eq!( + resolve_accel_with(v, true, false, false, false), + AccelPath::WhisperCpu + ); + assert_eq!( + resolve_accel_with(v, false, true, false, false), + AccelPath::WhisperVulkan + ); + } + } } #[cfg(windows)] @@ -317,10 +477,14 @@ mod dxgi { let name = String::from_utf16_lossy(&desc.Description) .trim_end_matches('\0') .to_string(); + // The adapter is physically present, but it's only *available* for + // routing if this build actually has an accel path for it — else + // `best()` would pick a GPU that no-ops back to CPU. + let available = !matches!(super::resolve_accel(id), super::AccelPath::WhisperCpu); out.push(BackendInfo { id, name, - available: true, + available, rank: rank_of(id), vram_mb: Some((desc.DedicatedVideoMemory / (1024 * 1024)) as u32), }); From 7fe0d87e55b1349a73f65e11af6952bf8ed027b5 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:27:28 -0500 Subject: [PATCH 02/28] feat(transcription): drive engine choice from AccelPath resolver (P0) load_transcriber now resolves the accel path instead of assuming a GPU BackendId has a working offload. Only requests whisper GPU offload for WhisperCuda/WhisperVulkan; NPU goes to the ONNX engine; everything else decodes on CPU and reports the backend actually used. --- src-tauri/src/commands.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 64e0373..af7fe59 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -119,8 +119,15 @@ fn load_transcriber( backend: BackendId, whisper_model: &Path, ) -> Result<(Box, BackendId), crate::transcription::TrxError> { + use crate::hardware::{resolve_accel, AccelPath}; + + // Resolve how this backend is actually served in *this* build (CUDA/Vulkan + // baked in? NPU/DirectML runtime present?) rather than assuming a GPU + // backend has a working accel path just because the hardware exists. + let path = resolve_accel(backend); + #[cfg(feature = "npu")] - if backend == BackendId::Npu { + if path == AccelPath::OnnxOpenVino { use crate::transcription::{onnx_models, OnnxNpuTranscriber}; if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) { let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL); @@ -134,10 +141,18 @@ fn load_transcriber( ); } } - match WhisperTranscriber::load(whisper_model, backend) { - Ok(t) => Ok((Box::new(t), backend)), - Err(e) if backend != BackendId::Cpu => { - tracing::warn!("backend {backend:?} failed to load ({e}); falling back to CPU"); + + // whisper.cpp path: only ask for GPU offload when the resolver picked a + // whisper GPU backend that's compiled in — otherwise a bogus `use_gpu` for a + // vendor with no accel path just no-ops. Anything else decodes on the CPU. + let whisper_backend = match path { + AccelPath::WhisperCuda | AccelPath::WhisperVulkan => backend, + _ => BackendId::Cpu, + }; + match WhisperTranscriber::load(whisper_model, whisper_backend) { + Ok(t) => Ok((Box::new(t), whisper_backend)), + Err(e) if whisper_backend != BackendId::Cpu => { + tracing::warn!("backend {whisper_backend:?} failed to load ({e}); falling back to CPU"); WhisperTranscriber::load(whisper_model, BackendId::Cpu) .map(|t| (Box::new(t) as Box, BackendId::Cpu)) } From c4658efe2882b8ee2a7ee4798e845e313e33c235 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:15 -0500 Subject: [PATCH 03/28] build(deps): enable ort DirectML EP for the non-Vulkan GPU path (P2) --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0d9f83..be6935c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,7 +55,7 @@ whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindin # dlopens the on-demand-downloaded runtime, so cargo compiles no C++/OpenVINO — # keeps the CPU-only build untouched (NFR-MNT-4). `rustfft` powers the log-mel # front-end (detok is hand-rolled off serde_json, no `tokenizers`/C dep). -ort = { version = "=2.0.0-rc.10", optional = true, default-features = false, features = ["load-dynamic", "openvino"] } +ort = { version = "=2.0.0-rc.10", optional = true, default-features = false, features = ["load-dynamic", "openvino", "directml"] } rustfft = { version = "6", optional = true } sherpa-rs = { version = "0.6", optional = true, default-features = false, features = ["download-binaries"] } # sherpa-onnx bindings (Phase 4, ADR-0005) tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export) From 2e8bb730a34b485daf6de41a4c39597b3ff419d5 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:16 -0500 Subject: [PATCH 04/28] feat(paths): DirectML runtime dir/dll/ready helpers (P2) --- src-tauri/src/paths.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 989369e..e4449fe 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -63,6 +63,26 @@ pub fn npu_runtime_ready() -> bool { npu_runtime_dll().exists() } +/// On-demand DirectML runtime (ONNX Runtime built with the DirectML EP, P2). +/// The non-Vulkan GPU path for AMD/Intel reuses the same Whisper-ONNX model as +/// the NPU path but needs a *different* `onnxruntime.dll` — Intel's OpenVINO ORT +/// build doesn't carry the DirectML EP — so it lives in its own runtime dir. +/// `DirectML.dll` itself ships with Windows 10 1903+, so the bundle is just ORT. +pub fn directml_runtime_dir() -> PathBuf { + wa_root().join("runtime").join("directml") +} + +/// The dlopen target for `ort` (load-dynamic) on the DirectML path; its presence +/// is the readiness signal that gates AMD/Intel non-Vulkan availability. +pub fn directml_runtime_dll() -> PathBuf { + directml_runtime_dir().join("onnxruntime.dll") +} + +/// True once the DirectML runtime has been staged (P2). +pub fn directml_runtime_ready() -> bool { + directml_runtime_dll().exists() +} + /// Fixed filenames pending T4.7 (diarization model management/selection in Settings). pub fn diarization_segmentation_model_file() -> PathBuf { models_dir().join("seg-pyannote-3.0.onnx") From 0f93f5947e827ab3602fabb1f78ff22702f16175 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:16 -0500 Subject: [PATCH 05/28] feat(transcription): generalize OnnxNpuTranscriber -> OnnxTranscriber with DirectML EP (P2) load() now selects OpenVINO (NPU) or DirectML (AMD/Intel GPU) by BackendId over the same ONNX artifacts; ensure_runtime_env takes the target runtime DLL. Adds an opt-in directml_transcribes_speech spike for on-GPU validation. --- src-tauri/src/transcription/npu.rs | 136 ++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/transcription/npu.rs b/src-tauri/src/transcription/npu.rs index 9fde9cb..b8de977 100644 --- a/src-tauri/src/transcription/npu.rs +++ b/src-tauri/src/transcription/npu.rs @@ -1,12 +1,15 @@ -//! NPU transcriber: Whisper ONNX on the Intel NPU via ONNX Runtime + OpenVINO -//! (T3.4, ADR-0004). Same `Transcriber` trait and `TranscriptSegment` output as -//! the whisper.cpp path, so callers never branch on engine. +//! ONNX transcriber: Whisper ONNX via ONNX Runtime, on either the Intel NPU +//! (OpenVINO EP, T3.4) or a DX12 GPU (DirectML EP, P2 — the non-Vulkan path for +//! AMD/Intel). One engine, EP chosen by the requested `BackendId`; same +//! `Transcriber` trait and `TranscriptSegment` output as the whisper.cpp path, +//! so callers never branch on engine. The ONNX model artifacts are identical +//! across EPs (an EP is a device backend, not a different graph). //! //! Split of work, validated by the T3.4 spike (encoder ~3.6× faster on NPU): -//! - **encoder** (fixed `[1,80,3000]` shape) runs on the **NPU** via OpenVINO — -//! the expensive graph, and the shape NPUs want; +//! - **encoder** (fixed `[1,80,3000]` shape) runs on the **accelerator** (NPU or +//! GPU) — the expensive graph, and the fixed shape accelerators want; //! - **decoder** (dynamic, autoregressive) runs greedy on the **CPU** EP — a -//! dynamic KV loop is a poor NPU fit and the cheap half anyway. +//! dynamic KV loop is a poor accelerator fit and the cheap half anyway. //! //! ponytail: greedy, no-KV-cache decode (re-feeds the full token prefix each //! step). Correct and simple; windows are short so the token count is small. @@ -14,7 +17,9 @@ use super::{AudioWindow, SegmentSink, Transcriber, TrxError}; use crate::models::{BackendId, TranscriptSegment}; -use ort::execution_providers::OpenVINOExecutionProvider; +use ort::execution_providers::{ + DirectMLExecutionProvider, ExecutionProviderDispatch, OpenVINOExecutionProvider, +}; use ort::session::Session; use ort::value::Tensor; use std::collections::HashMap; @@ -25,7 +30,7 @@ use std::sync::Mutex; /// Safety cap on generated tokens per 30 s window (Whisper's own max is 448). const MAX_NEW_TOKENS: usize = 224; -pub struct OnnxNpuTranscriber { +pub struct OnnxTranscriber { encoder: Mutex, decoder: Mutex, enc_input: String, @@ -44,15 +49,21 @@ pub struct OnnxNpuTranscriber { next_id: AtomicU64, } -impl OnnxNpuTranscriber { - /// Points `ort` (load-dynamic) at the on-demand-downloaded runtime and puts - /// its DLLs on the search path, unless the caller already set `ORT_DYLIB_PATH` - /// (the test harness does, to target a dev runtime). Idempotent. - fn ensure_runtime_env() { +impl OnnxTranscriber { + /// Points `ort` (load-dynamic) at the given on-demand-downloaded runtime DLL + /// and puts its sibling DLLs on the search path, unless the caller already + /// set `ORT_DYLIB_PATH` (the test harness does, to target a dev runtime). + /// Idempotent. + /// + /// `ORT_DYLIB_PATH` is process-global and `ort` dlopens it once, so a single + /// process run must not mix EPs from different runtime bundles (OpenVINO vs + /// DirectML). That's fine: `best()` picks one backend per session. + // ponytail: one runtime per process. If backend hot-swapping between NPU and + // DirectML in one run is ever needed, that's a re-init-ort problem to solve then. + fn ensure_runtime_env(dll: std::path::PathBuf) { if std::env::var_os("ORT_DYLIB_PATH").is_some() { return; } - let dll = crate::paths::npu_runtime_dll(); std::env::set_var("ORT_DYLIB_PATH", &dll); if let Some(dir) = dll.parent() { let path = std::env::var_os("PATH").unwrap_or_default(); @@ -185,32 +196,52 @@ impl OnnxNpuTranscriber { } } -impl Transcriber for OnnxNpuTranscriber { - /// `model` is the directory holding the ONNX artifacts (see `onnx_models`). - /// `backend` is expected to be `Npu`; a non-NPU value is rejected so the - /// dispatcher can fall back to whisper.cpp rather than us guessing. +impl Transcriber for OnnxTranscriber { + /// `model` is the directory holding the ONNX artifacts (see `onnx_models`) — + /// the same artifacts for every EP. `backend` selects the accelerator: + /// `Npu` → OpenVINO EP; `Amd`/`Intel` → DirectML EP (the non-Vulkan GPU + /// path). Any other value is rejected so the dispatcher can fall back to + /// whisper.cpp rather than us guessing. fn load(model: &Path, backend: BackendId) -> Result { - if backend != BackendId::Npu { - return Err(TrxError::Load(format!( - "OnnxNpuTranscriber only serves the NPU backend, got {backend:?}" - ))); - } - Self::ensure_runtime_env(); + // Pick the runtime bundle + encoder EP for the requested accelerator. + // error_on_failure makes a failed accelerator registration LOUD (Err) + // instead of a silent CPU fallback, so the dispatcher can cleanly drop + // to the whisper.cpp path (T3.4 spike lesson). + let (runtime_dll, encoder_ep): (std::path::PathBuf, ExecutionProviderDispatch) = + match backend { + BackendId::Npu => ( + crate::paths::npu_runtime_dll(), + OpenVINOExecutionProvider::default() + .with_device_type("NPU") + .build() + .error_on_failure(), + ), + // ponytail: DirectML device_id 0 = the default DX12 adapter. + // Map the DXGI adapter index here if a multi-GPU box ever needs + // to pin a specific AMD/Intel card. + BackendId::Amd | BackendId::Intel => ( + crate::paths::directml_runtime_dll(), + DirectMLExecutionProvider::default() + .with_device_id(0) + .build() + .error_on_failure(), + ), + other => { + return Err(TrxError::Load(format!( + "OnnxTranscriber serves NPU/AMD/Intel, got {other:?}" + ))) + } + }; + Self::ensure_runtime_env(runtime_dll); let encoder_path = model.join("encoder_model.onnx"); let decoder_path = model.join("decoder_model.onnx"); let tokenizer_path = model.join("tokenizer.json"); let gen_config_path = model.join("generation_config.json"); - // Encoder pinned to the NPU. error_on_failure makes a failed NPU - // registration LOUD (Err) instead of a silent CPU fallback, so the - // dispatcher can cleanly drop to the whisper.cpp path (T3.4 spike lesson). let encoder = Session::builder() .map_err(|e| TrxError::Load(e.to_string()))? - .with_execution_providers([OpenVINOExecutionProvider::default() - .with_device_type("NPU") - .build() - .error_on_failure()]) + .with_execution_providers([encoder_ep]) .map_err(|e| TrxError::Load(e.to_string()))? .commit_from_file(&encoder_path) .map_err(|e| TrxError::Load(e.to_string()))?; @@ -434,7 +465,7 @@ mod tests { }; let wav = std::env::var("WA_NPU_TEST_WAV").expect("set WA_NPU_TEST_WAV"); let t0 = std::time::Instant::now(); - let t = OnnxNpuTranscriber::load(Path::new(&model_dir), BackendId::Npu) + let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu) .expect("load NPU transcriber"); let load_ms = t0.elapsed().as_millis(); let t1 = std::time::Instant::now(); @@ -455,4 +486,45 @@ mod tests { ); } } + + /// Real DirectML GPU inference (P2), opt-in. Point `ORT_DYLIB_PATH` at a + /// DirectML-EP `onnxruntime.dll` (or stage `runtime\directml\`), reuse the + /// same ONNX model dir as the NPU spike, and run on the Intel/AMD GPU: + /// ORT_DYLIB_PATH=…directml\onnxruntime.dll WA_DML_MODEL_DIR=… \ + /// WA_DML_TEST_WAV=… cargo test --release --features npu \ + /// directml_transcribes -- --ignored --nocapture + #[test] + #[ignore = "requires a DirectML runtime + model + wav; run manually on a GPU"] + fn directml_transcribes_speech() { + let Some(model_dir) = std::env::var_os("WA_DML_MODEL_DIR") else { + eprintln!("skip: set WA_DML_MODEL_DIR"); + return; + }; + let wav = std::env::var("WA_DML_TEST_WAV").expect("set WA_DML_TEST_WAV"); + // Intel and AMD both take the DirectML EP; either BackendId exercises it. + let backend = match std::env::var("WA_DML_BACKEND").as_deref() { + Ok("amd") => BackendId::Amd, + _ => BackendId::Intel, + }; + let t0 = std::time::Instant::now(); + let t = OnnxTranscriber::load(Path::new(&model_dir), backend).expect("load DirectML"); + let load_ms = t0.elapsed().as_millis(); + let t1 = std::time::Instant::now(); + let segs = t.transcribe_file(Path::new(&wav)).expect("transcribe"); + let infer_ms = t1.elapsed().as_millis(); + let text = segs + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + .to_lowercase(); + eprintln!("[spike] backend={backend:?} load={load_ms}ms infer={infer_ms}ms text={text:?}"); + assert!(!text.trim().is_empty(), "transcript was empty"); + if let Ok(expect) = std::env::var("WA_DML_EXPECT") { + assert!( + text.contains(&expect.to_lowercase()), + "transcript {text:?} missing expected {expect:?}" + ); + } + } } From eb87bf4edbf4b5c074e457159582ecc3ee6654a9 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:17 -0500 Subject: [PATCH 06/28] refactor(transcription): re-export OnnxTranscriber; note ONNX tier covers NPU+DirectML (P2) --- src-tauri/src/transcription/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs index 0db9145..3de5be6 100644 --- a/src-tauri/src/transcription/mod.rs +++ b/src-tauri/src/transcription/mod.rs @@ -199,9 +199,10 @@ fn audio_ctx_for_window(n_samples: usize) -> i32 { ((window_secs / 30.0) * 1500.0).ceil().clamp(64.0, 1500.0) as i32 } -// NPU tier (T3.4): Whisper ONNX on the Intel NPU via ONNX Runtime + OpenVINO. -// A real `OnnxNpuTranscriber` implementing the same `Transcriber` trait; see -// `npu.rs`. Gated on the `npu` feature so the CPU-only build never pulls `ort`. +// ONNX tier: Whisper ONNX via ONNX Runtime on the Intel NPU (OpenVINO, T3.4) or +// a DX12 GPU (DirectML — the non-Vulkan AMD/Intel path, P2). One +// `OnnxTranscriber` implementing the same `Transcriber` trait; see `npu.rs`. +// Gated on the `npu` feature so the CPU-only build never pulls `ort`. #[cfg(feature = "npu")] pub mod mel; #[cfg(feature = "npu")] @@ -209,7 +210,7 @@ pub mod npu; #[cfg(feature = "npu")] pub mod onnx_models; #[cfg(feature = "npu")] -pub use npu::OnnxNpuTranscriber; +pub use npu::OnnxTranscriber; /// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono /// chunks from the `audio` service into fixed-size, **non-overlapping** windows From 9ae02cabfe3bda6a6706f4dce7323643c0613d91 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:17 -0500 Subject: [PATCH 07/28] docs(transcription): update OnnxTranscriber name in doc comments (P2) --- src-tauri/src/transcription/mel.rs | 2 +- src-tauri/src/transcription/onnx_models.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/transcription/mel.rs b/src-tauri/src/transcription/mel.rs index a5b9230..2d3d044 100644 --- a/src-tauri/src/transcription/mel.rs +++ b/src-tauri/src/transcription/mel.rs @@ -1,7 +1,7 @@ //! Whisper log-mel spectrogram front-end for the NPU path (T3.4). //! //! whisper.cpp computed the mel internally; ONNX Runtime does not, so the -//! `OnnxNpuTranscriber` needs its own. This reproduces HuggingFace's +//! `OnnxTranscriber` needs its own. This reproduces HuggingFace's //! `WhisperFeatureExtractor` **exactly** (same window/hop/filters/normalization) //! because the ONNX encoder we run was exported against that preprocessing — //! any deviation feeds the encoder out-of-distribution features and garbles the diff --git a/src-tauri/src/transcription/onnx_models.rs b/src-tauri/src/transcription/onnx_models.rs index 437f7ef..083d935 100644 --- a/src-tauri/src/transcription/onnx_models.rs +++ b/src-tauri/src/transcription/onnx_models.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; const REPO: &str = "onnx-community/whisper-base.en"; pub const DEFAULT_ONNX_MODEL: &str = "base.en"; -/// The four artifacts the `OnnxNpuTranscriber` needs, relative to the repo root. +/// The four artifacts the `OnnxTranscriber` needs, relative to the repo root. const FILES: &[(&str, &str)] = &[ ("encoder_model.onnx", "onnx/encoder_model.onnx"), ("decoder_model.onnx", "onnx/decoder_model.onnx"), @@ -34,7 +34,7 @@ fn onnx_dir(id: &str) -> PathBuf { models_dir().join("onnx-whisper").join(id) } -/// Directory holding a model's ONNX artifacts — what `OnnxNpuTranscriber::load` +/// Directory holding a model's ONNX artifacts — what `OnnxTranscriber::load` /// expects as its `model` path. pub fn model_dir(id: &str) -> PathBuf { onnx_dir(id) From 0f2940e52b5f9025ccab7a51246aa70a4933f38d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:27 -0500 Subject: [PATCH 08/28] feat(hardware): make directml_ready() real (npu feature + staged runtime) (P2) --- src-tauri/src/hardware/mod.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/hardware/mod.rs b/src-tauri/src/hardware/mod.rs index 7f5ce08..013bbb9 100644 --- a/src-tauri/src/hardware/mod.rs +++ b/src-tauri/src/hardware/mod.rs @@ -116,14 +116,10 @@ pub fn resolve_accel(backend: BackendId) -> AccelPath { ) } -/// Whether the DirectML-enabled ONNX runtime is present (P2). Stubbed `false` -/// until the DirectML runtime + transcriber land, so P0 resolution treats it as -/// unavailable everywhere. +/// Whether the DirectML-enabled ONNX engine is usable in this binary: the `ort` +/// engine must be compiled in (`npu` feature) and its DirectML runtime staged. fn directml_ready() -> bool { - // ponytail: filled in by P2 (paths::directml_runtime_ready + a real - // OnnxDirectML transcriber). Until then AMD/Intel non-Vulkan builds resolve - // to CPU, which is the honest, pre-P2 state. - false + cfg!(feature = "npu") && crate::paths::directml_runtime_ready() } fn cpu_backend() -> BackendInfo { @@ -155,7 +151,7 @@ pub fn npu_hardware_present() -> bool { /// download kept out of the base installer (see `paths::npu_runtime_ready`). /// `available` gates routing, so it only flips true once BOTH the chip and its /// runtime are in place — which, by build order (T3.4 step 5 → 1-3), is also -/// after the `OnnxNpuTranscriber` path exists. +/// after the `OnnxTranscriber` path exists. fn npu_backend() -> BackendInfo { let present = npu_hardware_present(); // Honest availability: the chip must be present AND resolve to a real accel From b69c91a5986c2e6dcafc77a334023c9bcc7eaacf Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:27 -0500 Subject: [PATCH 09/28] feat(commands): route AMD/Intel to DirectML ONNX engine + download_directml_package (P2) load_transcriber dispatches OnnxOpenVino/OnnxDirectML through one OnnxTranscriber block. download_and_extract_runtime is parameterized (sha + readiness) so the new stage_directml_runtime reuses it; hardware_status reports a directml package field. --- src-tauri/src/commands.rs | 98 ++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af7fe59..e645b71 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -126,18 +126,22 @@ fn load_transcriber( // backend has a working accel path just because the hardware exists. let path = resolve_accel(backend); + // ONNX engine — NPU (OpenVINO EP) or AMD/Intel GPU (DirectML EP). Same model + // artifacts either way; the EP is chosen inside OnnxTranscriber::load from + // `backend`, and the resolved `backend` is reported so the UI shows the real + // engine. #[cfg(feature = "npu")] - if path == AccelPath::OnnxOpenVino { - use crate::transcription::{onnx_models, OnnxNpuTranscriber}; + if matches!(path, AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML) { + use crate::transcription::{onnx_models, OnnxTranscriber}; if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) { let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL); - match OnnxNpuTranscriber::load(&dir, BackendId::Npu) { - Ok(t) => return Ok((Box::new(t), BackendId::Npu)), - Err(e) => tracing::warn!("NPU engine load failed ({e}); falling back to CPU"), + match OnnxTranscriber::load(&dir, backend) { + Ok(t) => return Ok((Box::new(t), backend)), + Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"), } } else { tracing::warn!( - "NPU selected but ONNX model not installed; falling back to whisper.cpp" + "ONNX backend selected but model not installed; falling back to whisper.cpp" ); } } @@ -852,6 +856,12 @@ pub async fn hardware_status() -> WaResult { "runtimeReady": crate::paths::npu_runtime_ready(), "modelInstalled": npu_model_installed(), }, + // DirectML GPU package (P2): shares the ONNX model with the NPU path; + // only the runtime differs. Drives a future Settings ▸ Hardware indicator. + "directml": { + "runtimeReady": crate::paths::directml_runtime_ready(), + "modelInstalled": npu_model_installed(), + }, })) } @@ -928,7 +938,35 @@ async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> { } let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string()); - download_and_extract_runtime(app, &dir, &url).await + download_and_extract_runtime( + app, + &dir, + &url, + NPU_RUNTIME_SHA256, + crate::paths::npu_runtime_ready, + ) + .await +} + +/// Stages the DirectML ONNX Runtime into the app's DirectML runtime dir (P2). +/// Unlike the NPU bundle there's no hosted default yet, so the URL must come +/// from `WA_DIRECTML_RUNTIME_URL` — or drop `onnxruntime.dll` into +/// `runtime\directml\` manually (dev testing can also just set `ORT_DYLIB_PATH`). +#[cfg(feature = "npu")] +async fn stage_directml_runtime(app: &AppHandle) -> WaResult<()> { + if crate::paths::directml_runtime_ready() { + return Ok(()); + } + let dir = crate::paths::directml_runtime_dir(); + std::fs::create_dir_all(&dir).map_err(|e| WaError::new("directml", e.to_string()))?; + let url = std::env::var("WA_DIRECTML_RUNTIME_URL").map_err(|_| { + WaError::new( + "directml", + "no DirectML runtime: set WA_DIRECTML_RUNTIME_URL or place onnxruntime.dll in runtime\\directml\\", + ) + })?; + // Empty SHA: no published bundle to pin yet (ponytail — fill in once hosted). + download_and_extract_runtime(app, &dir, &url, "", crate::paths::directml_runtime_ready).await } #[cfg(feature = "npu")] @@ -966,10 +1004,21 @@ fn stage_npu_runtime_from_local( Ok(()) } -/// Downloads the runtime bundle (streaming progress + SHA-256 check) and unzips -/// its DLLs flat into `dir`. +/// Downloads a runtime bundle (streaming progress + SHA-256 check) and unzips +/// its DLLs flat into `dir`. `sha256` empty disables the integrity check; +/// `ready` is the on-disk readiness predicate for the target runtime (NPU or +/// DirectML), so this one function serves both. +// ponytail: progress event + error domain stay "npu"/"npu://download" even on +// the DirectML path — cosmetic only, no frontend consumer for a directml +// channel yet. Split them out when the Settings UI grows a DirectML indicator. #[cfg(feature = "npu")] -async fn download_and_extract_runtime(app: &AppHandle, dir: &Path, url: &str) -> WaResult<()> { +async fn download_and_extract_runtime( + app: &AppHandle, + dir: &Path, + url: &str, + sha256: &str, + ready: fn() -> bool, +) -> WaResult<()> { use futures_util::StreamExt; use sha2::{Digest, Sha256}; @@ -1002,7 +1051,7 @@ async fn download_and_extract_runtime(app: &AppHandle, dir: &Path, url: &str) -> drop(file); let digest = format!("{:x}", hasher.finalize()); - if !NPU_RUNTIME_SHA256.is_empty() && digest != NPU_RUNTIME_SHA256 { + if !sha256.is_empty() && digest != sha256 { let _ = std::fs::remove_file(&tmp); return Err(WaError::new("npu", "runtime bundle checksum mismatch")); } @@ -1016,7 +1065,7 @@ async fn download_and_extract_runtime(app: &AppHandle, dir: &Path, url: &str) -> .map_err(|e| WaError::new("npu", e))?; let _ = std::fs::remove_file(&tmp); - if !crate::paths::npu_runtime_ready() { + if !ready() { return Err(WaError::new( "npu", "runtime bundle extracted but onnxruntime.dll is missing", @@ -1070,6 +1119,31 @@ pub async fn download_npu_package(app: AppHandle) -> WaResult<()> { } } +/// Downloads everything the DirectML GPU engine needs — the same Whisper ONNX +/// model as the NPU path, plus the DirectML runtime (P2). The non-Vulkan GPU +/// path for AMD/Intel. +#[tauri::command] +pub async fn download_directml_package(app: AppHandle) -> WaResult<()> { + #[cfg(feature = "npu")] + { + download_npu_model(app.clone()).await?; // identical ONNX artifacts + stage_directml_runtime(&app).await?; + let _ = app.emit( + "directml://download", + serde_json::json!({ "stage": "done", "ready": crate::paths::directml_runtime_ready() }), + ); + Ok(()) + } + #[cfg(not(feature = "npu"))] + { + let _ = app; + Err(WaError::new( + "directml", + "this build has no ONNX/DirectML support", + )) + } +} + #[tauri::command] pub async fn list_models() -> WaResult> { let settings = load_settings(); From ab5aa4e98656c558562c10cfeed22c602aa5fbfe Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 5 Jul 2026 19:40:28 -0500 Subject: [PATCH 10/28] feat(commands): register download_directml_package command (P2) --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d39655..0177e78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -176,6 +176,7 @@ pub fn run() { commands::set_preferred_backend, commands::list_models, commands::download_npu_package, + commands::download_directml_package, commands::list_diarization_models, commands::download_model, commands::remove_model, From 281ccf0b182630b747d429c5d43baad25d34048d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:21:50 -0500 Subject: [PATCH 11/28] feat(hardware): directml_would_help() gate for the Settings package card (P2) True only when staging DirectML would light up a present GPU this build can't otherwise accelerate: npu compiled, no Vulkan baked in, and an AMD/Intel GPU present (or NVIDIA without CUDA). --- src-tauri/src/hardware/mod.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src-tauri/src/hardware/mod.rs b/src-tauri/src/hardware/mod.rs index 013bbb9..4d23df8 100644 --- a/src-tauri/src/hardware/mod.rs +++ b/src-tauri/src/hardware/mod.rs @@ -122,6 +122,31 @@ fn directml_ready() -> bool { cfg!(feature = "npu") && crate::paths::directml_runtime_ready() } +/// True when staging the DirectML runtime would light up a physically-present +/// GPU this build can't otherwise accelerate: the ONNX engine is compiled, no +/// Vulkan backend is baked in (which would already cover every GPU), and a GPU +/// that would then route to DirectML is present — AMD/Intel always, NVIDIA only +/// when there's no CUDA fast path. Drives whether Settings offers the package. +pub fn directml_would_help() -> bool { + if !cfg!(feature = "npu") || cfg!(feature = "vulkan") { + return false; + } + #[cfg(windows)] + { + // Presence, not availability — `available` is false pre-staging, but the + // enumerated entries still tell us which GPUs physically exist. + dxgi::enumerate_gpus().iter().any(|b| match b.id { + BackendId::Amd | BackendId::Intel => true, + BackendId::Nvidia => !cfg!(feature = "cuda"), + _ => false, + }) + } + #[cfg(not(windows))] + { + false + } +} + fn cpu_backend() -> BackendInfo { BackendInfo { id: BackendId::Cpu, From ceb7e591642ad9976753cae730128514d288f2f6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:21:50 -0500 Subject: [PATCH 12/28] feat(commands): expose directml.applicable in hardware_status (P2) --- src-tauri/src/commands.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e645b71..84d1304 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -857,8 +857,10 @@ pub async fn hardware_status() -> WaResult { "modelInstalled": npu_model_installed(), }, // DirectML GPU package (P2): shares the ONNX model with the NPU path; - // only the runtime differs. Drives a future Settings ▸ Hardware indicator. + // only the runtime differs. `applicable` gates the Settings ▸ Hardware + // card so it only shows when DirectML would actually help this build. "directml": { + "applicable": crate::hardware::directml_would_help(), "runtimeReady": crate::paths::directml_runtime_ready(), "modelInstalled": npu_model_installed(), }, From 1b743dad67ef8b0e1c1177cb14affd3959be18af Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:21:51 -0500 Subject: [PATCH 13/28] feat(api): DirectML package status + downloadDirectmlPackage binding (P2) --- src/lib/api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index bbea864..a9dcefb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -39,6 +39,8 @@ export interface HardwareStatus { estRtf: number; /** NPU package state (T3.4): chip detected, runtime staged, model fetched. */ npu?: { present: boolean; runtimeReady: boolean; modelInstalled: boolean }; + /** DirectML GPU package (P2): applicable = staging would help this build. */ + directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean }; } export interface LlmStatus { @@ -322,6 +324,7 @@ export const api = { setPreferredBackend: (backend: BackendId | "auto") => invoke("set_preferred_backend", { args: { backend } }), downloadNpuPackage: () => invoke("download_npu_package"), + downloadDirectmlPackage: () => invoke("download_directml_package"), listModels: () => invoke("list_models"), downloadModel: (id: string, kind: "whisper" = "whisper") => invoke("download_model", { args: { kind, id } }), From 1b74e0b320db89025414944c4687479917f7256a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:21:51 -0500 Subject: [PATCH 14/28] feat(settings): DirectML GPU acceleration package card (P2) Mirrors the NPU package card, shown only when hardware_status marks DirectML applicable; downloads model+runtime via download_directml_package and reloads hardware to flip to Ready. --- src/lib/views/Settings.svelte | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 5bcddb2..7243c95 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -269,6 +269,25 @@ } } + // ---- DirectML package download (P2) ---- + // ponytail: the await resolves when model+runtime are both staged, so the + // busy flag is the whole progress story here — no % listener like NPU has. + let dmlBusy = $state(false); + let dmlMsg = $state(null); + async function downloadDirectmlPackage() { + dmlBusy = true; + dmlMsg = "Downloading…"; + try { + await api.downloadDirectmlPackage(); + await settings.loadHardware(); + dmlMsg = "DirectML package ready."; + } catch (e) { + dmlMsg = `Failed: ${errorMessage(e)}`; + } finally { + dmlBusy = false; + } + } + onMount(() => { settings.load(); calendar.load(); @@ -486,6 +505,35 @@ {#if npuMsg}

{npuMsg}

{/if} {/if} + + {#if settings.hardware.directml?.applicable} + {@const dml = settings.hardware.directml} + {@const dmlReady = dml.runtimeReady && dml.modelInstalled} + +
+
+
+ {#if !dmlReady} +

+ Your GPU can be accelerated without Vulkan via DirectML — a one-time package + (Whisper ONNX model{dml.runtimeReady ? "" : " + DirectML runtime"}). +

+ + {/if} + {#if dmlMsg}

{dmlMsg}

{/if} +
+ {/if} {:else}

Hardware detection unavailable.

{/if} From 035abb93ea0f4723ba88571f771bd566b0bc2904 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:17 -0500 Subject: [PATCH 15/28] build: bake short git commit hash into WA_GIT_HASH for the About page --- src-tauri/build.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 261851f..4784e65 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,18 @@ fn main() { + // Bake the short commit hash in for the About page. Falls back to "unknown" + // in a non-git build (e.g. a source tarball). `logs/HEAD` is the reflog — it + // gets a line on every commit/checkout, so watching it re-runs this script + // when the hash changes (build.rs output is otherwise cached). + let hash = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=WA_GIT_HASH={hash}"); + println!("cargo:rerun-if-changed=../.git/logs/HEAD"); + tauri_build::build(); } From 0317649b48e4d83e73f2fa4b8d001d3758fcbfc3 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:18 -0500 Subject: [PATCH 16/28] feat(commands): app_info (version+commit) and open_url for the About page --- src-tauri/src/commands.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 84d1304..dbcc873 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -834,6 +834,42 @@ pub async fn map_speaker_to_participant( Ok(()) } +// ---- App metadata (About page) ---- + +/// Version + build commit for the About page. Version comes from Cargo; the +/// short commit hash is baked in at build time by `build.rs` (`WA_GIT_HASH`). +#[tauri::command] +pub async fn app_info() -> WaResult { + Ok(serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "commit": env!("WA_GIT_HASH"), + })) +} + +/// Opens an http(s) URL in the user's default browser (About page source link). +/// Windows-only (WA is Windows-native, ADR-0001). The scheme is validated so +/// this can't be coerced into launching a local path or program, and `explorer` +/// receives the URL as a single argv (no shell), so there's no injection surface. +#[tauri::command] +pub async fn open_url(url: String) -> WaResult<()> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(WaError::new("app", "only http(s) URLs may be opened")); + } + #[cfg(windows)] + { + std::process::Command::new("explorer") + .arg(&url) + .spawn() + .map_err(|e| WaError::new("app", e.to_string()))?; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = url; + Err(WaError::new("app", "unsupported platform")) + } +} + // ---- Hardware + models (Phase 3) ---- #[tauri::command] From 42c2aedfd40bda16722f60503c84094ce54dc5c7 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:18 -0500 Subject: [PATCH 17/28] feat(commands): register app_info + open_url --- src-tauri/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0177e78..b5c334f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -172,6 +172,8 @@ pub fn run() { commands::set_recording_retention, commands::acknowledge_recording_consent, commands::resume_transcription, + commands::app_info, + commands::open_url, commands::hardware_status, commands::set_preferred_backend, commands::list_models, From a51d7a777b2df021fb49fb7e83882248c641e369 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:19 -0500 Subject: [PATCH 18/28] feat(api): AppInfo type + appInfo/openUrl bindings --- src/lib/api.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index a9dcefb..f530ea5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -32,6 +32,11 @@ export interface BackendInfo { vram_mb: number | null; } +export interface AppInfo { + version: string; + commit: string; +} + export interface HardwareStatus { backends: BackendInfo[]; active: BackendId; @@ -320,6 +325,8 @@ export const api = { invoke("set_recording_retention", { meetingId, record }), acknowledgeRecordingConsent: () => invoke("acknowledge_recording_consent"), + appInfo: () => invoke("app_info"), + openUrl: (url: string) => invoke("open_url", { url }), hardwareStatus: () => invoke("hardware_status"), setPreferredBackend: (backend: BackendId | "auto") => invoke("set_preferred_backend", { args: { backend } }), From 31b3cfd5a7a01c13d31aa1fe974aa0d913d0c9ce Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:19 -0500 Subject: [PATCH 19/28] fix(settings): wrap nav tabs so the close button isn't cut off; add About page The fixed-width settings panel overflowed horizontally because the tab nav didn't wrap, pushing the X close button off the edge and adding a horizontal scrollbar. nav now flex-wraps. Adds an About section: version, build commit, and a link to the source repo (opened via open_url). --- src/lib/views/Settings.svelte | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 7243c95..52c7a46 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -24,6 +24,7 @@ RefreshCw, ChevronRight, RotateCcw, + Info, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -36,9 +37,13 @@ let { onClose }: { onClose: () => void } = $props(); let section = $state< - "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" + "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" | "about" >("recording"); + // ---- About (version + build commit + source) ---- + const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist"; + let appInfo = $state<{ version: string; commit: string } | null>(null); + // ---- AI summary provider (T5.2, FR-LLM-1) ---- let llmProvider = $state(settings.settings.llm_provider); let llmEndpoint = $state(settings.settings.llm_endpoint); @@ -291,6 +296,10 @@ onMount(() => { settings.load(); calendar.load(); + api + .appInfo() + .then((i) => (appInfo = i)) + .catch(() => (appInfo = null)); const un = events.onNpuDownload((p) => { if (p.stage === "model") npuMsg = p.total ? `Model ${Math.round((100 * (p.received ?? 0)) / p.total)}%` : "Model…"; @@ -408,6 +417,9 @@ + +

+ {/if} @@ -1151,13 +1179,19 @@ top: 0; background: var(--bg-elevated); display: flex; - align-items: center; + align-items: flex-start; gap: 1rem; padding: 0.9rem 0; border-bottom: 1px solid var(--border); } + /* nav takes the middle and wraps its tabs onto a second row instead of + overflowing the fixed-width panel — that overflow was pushing the close + button off the panel edge and adding a horizontal scrollbar. */ nav { display: flex; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; gap: 0.25rem; } nav button, @@ -1187,6 +1221,7 @@ .close { display: grid; place-items: center; + flex: 0 0 auto; margin-left: auto; width: 30px; height: 30px; @@ -1417,6 +1452,23 @@ .link.danger { color: var(--danger); } + .about-name { + font-size: 1.05rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem; + } + .about-ver { + color: var(--muted); + font-weight: 500; + } + /* The source link reads as a link (accent + underline), unlike the muted + .link buttons used for secondary actions elsewhere. */ + .link.source { + color: var(--accent); + text-decoration: underline; + padding: 0; + font: inherit; + } .test { display: inline-flex; align-items: center; From 0d624161276d21045869fbc0ce02d52f9743a0b0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 07:46:29 -0500 Subject: [PATCH 20/28] chore(release): bump version to 0.1.5 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index cf6c9dc..ee02251 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whispassist", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "description": "Privacy-first, fully local Windows meeting assistant.", "license": "MIT OR Apache-2.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 785e8bc..339eae0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "whispassist" -version = "0.1.4" +version = "0.1.5" dependencies = [ "argon2", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be6935c..ae23d2d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whispassist" -version = "0.1.4" +version = "0.1.5" description = "Privacy-first, fully local Windows meeting assistant" authors = ["WhispAssist contributors"] license = "MIT OR Apache-2.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3e7487e..c99af92 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WhispAssist", - "version": "0.1.4", + "version": "0.1.5", "identifier": "bet.dou.whispassist", "build": { "frontendDist": "../dist", From 4442f809d1d9f9f7d28b9060e3cf760d90eeb823 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 6 Jul 2026 08:01:36 -0500 Subject: [PATCH 21/28] fix(theme): dark-mode native controls (Templates dropdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set color-scheme on the theme roots so native controls render in the active theme, and give the header template