diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d2c89..46ad068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ jobs: # source (no native deps), so it runs cleanly on Linux even though the app targets macOS/Windows. - name: rustfmt (app crate) run: cargo fmt --manifest-path app/src-tauri/Cargo.toml -- --check + # Same for the new Linux native crate — excluded from the root workspace like the other + # per-OS crates, so `cargo fmt --all` above skips it. + - name: rustfmt (linux pulseaudio crate) + run: cargo fmt --manifest-path crates/wisp-pulseaudio/Cargo.toml -- --check # Exercise the exact dependency-free module used by build.rs with VC143/VC145-shaped fixtures. - name: Test Windows runtime staging run: | @@ -114,6 +118,51 @@ jobs: shell: bash run: ./scripts/verify-windows-runtime.sh app/src-tauri/windows-runtime + # Proves the Linux app builds — including the Linux-ONLY code no other CI job compiles: + # the PulseAudio monitor source and the Linux WebRTC AEC build (meson + ninja + libclang). + # Ubuntu 22.04 keeps the glibc floor low; the default build has no whisper.cpp/Vulkan, so no + # Vulkan SDK is needed here (the release job adds it for the GPU build). + linux-app: + name: app builds (linux) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Linux system deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config \ + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \ + libasound2-dev libpulse-dev ninja-build libclang-dev python3-pip + # jammy's apt meson is 0.61; the vendored WebRTC AEC build requires meson >= 0.63. + sudo pip3 install "meson>=0.63,<2" + # glslc is not in jammy's archive — pull the Vulkan toolchain from LunarG's jammy repo. + wget -qO /tmp/lunarg.asc https://packages.lunarg.com/lunarg-signing-key-pub.asc + wget -qO /tmp/lunarg.list https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list + sudo install -m644 /tmp/lunarg.asc /etc/apt/trusted.gpg.d/lunarg.asc + sudo install -m644 /tmp/lunarg.list /etc/apt/sources.list.d/lunarg-vulkan-jammy.list + sudo apt-get update + sudo apt-get install -y vulkan-sdk + - name: Build the frontend (tauri-build needs the dist) + working-directory: app + run: | + npm ci + npm run build + # Same staging race as Windows: the app's build.rs stages sherpa's .so files, so sherpa must + # build first or the resources check fails on the not-yet-present libs. + - name: Stage sherpa runtime libs + run: cargo build --manifest-path app/src-tauri/Cargo.toml -p wisp-engine-sherpa + - name: Build the app + run: cargo build --manifest-path app/src-tauri/Cargo.toml + # The Vulkan whisper.cpp backend is the port's GPU path; compile it on every PR so a breakage + # in build_linux_vulkan (cmake/glslc plumbing) cannot hide until release day. + - name: Build the app (whisper-vulkan) + run: cargo build --manifest-path app/src-tauri/Cargo.toml --features whisper-vulkan + # Proves the macOS app builds — including the macOS-ONLY code no other CI job ever compiles: # ScreenCaptureKit system audio, the WebRTC AEC3 echo canceller, Metal whisper.cpp, and Apple # SpeechAnalyzer. Without this job a macOS-only regression slips past CI and only surfaces on a real diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c49815..3e30582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,12 @@ # # macOS: Wisp__aarch64.dmg (Apple Silicon only) # Windows: Wisp__x64-setup.exe (NSIS, smaller) + Wisp__x64_en-US.msi +# Linux: Wisp__amd64.deb + Wisp__amd64.AppImage # # Intel macOS is intentionally unsupported (Metal/screencapture need the macOS 15 SDK no Intel runner -# provides; WebRTC AEC won't cross-compile from Apple Silicon). The Windows build is mic + sherpa-onnx -# (system audio / AEC / Metal are macOS-only and degrade gracefully). +# provides; WebRTC AEC won't cross-compile from Apple Silicon). The Windows and Linux builds are the +# universal GPU installers (whisper.cpp on Vulkan, with ggml's CPU fallback when there's no Vulkan +# device; sherpa stays CPU). # # Trigger: push a `v*` tag (e.g. `git tag v0.2.0 && git push --tags`), or run manually with a tag. # @@ -57,6 +59,12 @@ jobs: - os: windows-latest label: Windows x64 args: "--features whisper-vulkan --config src-tauri/tauri.vulkan.conf.json" + # Same universal GPU build on Linux (Vulkan whisper.cpp + PulseAudio monitor system audio + + # WebRTC AEC). 22.04 keeps the glibc floor usable on the oldest supported Ubuntu LTS; the + # Vulkan loader is bundled into the artifacts like the Windows installer's vulkan-1.dll. + - os: ubuntu-22.04 + label: Linux x64 + args: "--features whisper-vulkan" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -80,11 +88,33 @@ jobs: with: node-version: 20 - # The WebRTC AEC crate's bundled build compiles vendored WebRTC with meson + ninja (macOS only). + # The WebRTC AEC crate's bundled build compiles vendored WebRTC with meson + ninja. - name: Install meson + ninja if: runner.os == 'macOS' run: brew install meson ninja + # Linux system deps: Tauri's webkit/gtk stack, ALSA for cpal mic capture, libpulse for the + # monitor system-audio source, meson+ninja for the WebRTC AEC build, and the Vulkan SDK for + # the whisper.cpp Vulkan backend. glslc is not packaged on Ubuntu 22.04, so the Vulkan pieces + # come from LunarG's official jammy repo. At package time `app/src-tauri/build.rs` bundles the + # loader (`libvulkan.so.1`) into the artifacts — the same approach as `vulkan-1.dll` on Windows — + # and the deb also declares `libvulkan1` as a dependency. + - name: Install Linux system deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config curl wget file gnupg ca-certificates \ + libwebkit2gtk-4.1-dev librsvg2-dev patchelf \ + libasound2-dev libpulse-dev meson ninja-build libclang-dev \ + libayatana-appindicator3-dev + wget -qO /tmp/lunarg.asc https://packages.lunarg.com/lunarg-signing-key-pub.asc + wget -qO /tmp/lunarg.list https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list + sudo install -m644 /tmp/lunarg.asc /etc/apt/trusted.gpg.d/lunarg.asc + sudo install -m644 /tmp/lunarg.list /etc/apt/sources.list.d/lunarg-vulkan-jammy.list + sudo apt-get update + sudo apt-get install -y vulkan-sdk + # Windows ships the universal GPU installer (whisper.cpp + Vulkan). ggml-vulkan's vulkan-shaders-gen # sub-build nests deeply and overruns Windows' 260-char MAX_PATH from the default target dir, so # shorten it; the Ninja generator needs cl.exe on PATH; and the SDK provides the headers + diff --git a/.gitignore b/.gitignore index 02bc3ba..8658adb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /target **/target +# Build-time staging dir for the sherpa/onnxruntime .so copies the Linux bundle ships +/app/src-tauri/linux-runtime/ + # Node / frontend node_modules/ dist/ diff --git a/Cargo.toml b/Cargo.toml index 1b44caa..a88af8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,14 +3,16 @@ resolver = "2" members = ["crates/*"] # Kept out of the library CI build: the Tauri app, and the per-OS native crates (heavy vendored # libs built at compile time — sherpa/onnxruntime, whisper.cpp/Metal — or OS frameworks: -# ScreenCaptureKit, WebRTC AEC3, WASAPI loopback). The Windows one is covered by the app build on -# the Windows CI runner; the macOS ones by running the app on a Mac. +# ScreenCaptureKit, WebRTC AEC3, WASAPI loopback, PulseAudio monitor). The Windows one is covered +# by the app build on the Windows CI runner; the macOS ones by running the app on a Mac; the Linux +# one by the app build on the Linux release runner. exclude = [ "app/src-tauri", "crates/wisp-engine-sherpa", "crates/wisp-engine-whisper-cpp", "crates/wisp-screencapture", "crates/wisp-loopback", + "crates/wisp-pulseaudio", "crates/wisp-aec", "crates/wisp-embed", ] diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 6ed6c5d..e7ef37b 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "wisp-loopback", "wisp-models", "wisp-pipeline", + "wisp-pulseaudio", "wisp-screencapture", "wisp-textinject", ] @@ -2650,6 +2651,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libpulse-binding" +version = "2.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libpulse-sys", + "num-derive", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "libredox" version = "0.1.17" @@ -6823,6 +6851,14 @@ dependencies = [ "wisp-core", ] +[[package]] +name = "wisp-pulseaudio" +version = "0.0.0" +dependencies = [ + "libpulse-binding", + "wisp-core", +] + [[package]] name = "wisp-screencapture" version = "0.0.0" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6c1b067..063ce9d 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -39,8 +39,9 @@ sherpa-cpu = ["wisp-engine-sherpa/download-binaries"] # weekly smoke workflow (cargo build --no-default-features --features sherpa-gpu) — a ~2h compile, # too slow to gate every PR. Mutually exclusive with sherpa-cpu (sherpa-rs-sys enforces this). sherpa-gpu = ["wisp-engine-sherpa/directml"] -# whisper.cpp with the Vulkan GPU backend on Windows (generic GPU — AMD/Intel/NVIDIA — with a CPU -# fallback). Off by default; the Windows GPU release turns it on. No effect on macOS (always Metal). +# whisper.cpp with the Vulkan GPU backend on Windows and Linux (generic GPU — AMD/Intel/NVIDIA — +# with a CPU fallback). Off by default; the Windows and Linux GPU releases turn it on. No effect on +# macOS (always Metal). whisper-vulkan = ["wisp-engine-whisper-cpp/vulkan", "wisp-models/whisper-vulkan"] [build-dependencies] @@ -100,3 +101,13 @@ wisp-engine-whisper-cpp = { path = "../../crates/wisp-engine-whisper-cpp" } # Total physical memory (GlobalMemoryStatusEx) to recommend a machine-appropriate model. Same # `windows` major as wisp-loopback, so no extra version in the tree. windows = { version = "0.61", features = ["Win32_System_SystemInformation"] } + +# Linux-only native deps. System-audio capture via the PulseAudio/PipeWire monitor — the counterpart +# to macOS ScreenCaptureKit and Windows WASAPI loopback, the same one-click "capture the meeting" +# experience — plus WebRTC AEC3 echo cancellation (also used on macOS). +[target.'cfg(target_os = "linux")'.dependencies] +wisp-pulseaudio = { path = "../../crates/wisp-pulseaudio" } +wisp-aec = { path = "../../crates/wisp-aec" } +# whisper.cpp ASR engine — built with the Vulkan GPU backend only under the `whisper-vulkan` feature +# (generic GPU + CPU fallback); an empty shell in the default CPU build. +wisp-engine-whisper-cpp = { path = "../../crates/wisp-engine-whisper-cpp" } diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index cb0b0e0..662018c 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -24,9 +24,159 @@ fn main() { #[cfg(target_os = "windows")] stage_windows_runtime_libs(); + #[cfg(target_os = "linux")] + { + // The sherpa-onnx + onnxruntime shared libs the binary links against land in the cargo + // target dir at build time. `tauri dev` finds them via `$ORIGIN`; the .deb/AppImage ship + // them under /lib/Wisp (the Tauri resource dir). Emit RPATH (not the modern + // RUNPATH, which does not apply transitively) so libsherpa-onnx-c-api.so also resolves + // its own dep — the unversioned libonnxruntime.so — from the same directory. + println!("cargo:rustc-link-arg=-Wl,--disable-new-dtags"); + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/../lib/Wisp"); + stage_linux_runtime_libs(); + stage_linux_vulkan_loader(); + } + tauri_build::build() } +/// Copies the sherpa-onnx + onnxruntime runtime libs from the cargo target dir into +/// `linux-runtime/` (next to this build script) so `tauri.linux.conf.json` can bundle them into +/// the installer's resource dir. Every `lib*.so*` the binary needs is staged flat, so the +/// `$ORIGIN/../lib/Wisp` rpath finds each DT_NEEDED name (the sherpa-rs-sys prebuilt libs are +/// unversioned — plain `libonnxruntime.so`, `libsherpa-onnx-c-api.so`, `libsherpa-onnx-cxx-api.so`). +#[cfg(target_os = "linux")] +fn stage_linux_runtime_libs() { + use std::path::Path; + + let target_dir = cargo_target_dir_linux(); + let staged = Path::new(env!("CARGO_MANIFEST_DIR")).join("linux-runtime"); + std::fs::create_dir_all(&staged).expect("create linux-runtime dir"); + + let mut staged_any = false; + let mut seen = std::collections::HashSet::new(); + for entry in std::fs::read_dir(&target_dir) + .unwrap_or_else(|e| panic!("read {}: {e}", target_dir.display())) + { + let entry = entry.expect("read target dir entry"); + let name = entry.file_name().to_string_lossy().into_owned(); + let is_runtime_lib = (name.starts_with("libsherpa-onnx") || name.starts_with("libonnxruntime")) + && name.contains(".so") + // The C++ wrapper lib is unused — the Rust FFI binds the C API only, and neither the + // macOS nor the Windows upstream installer ships it. + && name != "libsherpa-onnx-cxx-api.so"; + if !is_runtime_lib { + continue; + } + let src = entry.path(); + if !src.is_file() { + continue; // dirs, fifos etc. — a soname symlink resolves through and copies under its link name + } + let dst = staged.join(&name); + let stale = match (dst.metadata(), src.metadata()) { + (Ok(d), Ok(s)) => d.len() != s.len() || s.modified().ok() > d.modified().ok(), + _ => true, + }; + if stale { + std::fs::copy(&src, &dst).unwrap_or_else(|e| panic!("stage {}: {e}", src.display())); + } + seen.insert(name); + staged_any = true; + } + + // Drop staged libs the target dir no longer produces (e.g. after a sherpa version bump), so + // linux-runtime can't keep shipping stale copies. + for entry in std::fs::read_dir(&staged) + .unwrap_or_else(|e| panic!("read {}: {e}", staged.display())) + .flatten() + { + let name = entry.file_name().to_string_lossy().into_owned(); + let stale_lib = (name.starts_with("libsherpa-onnx") || name.starts_with("libonnxruntime")) + && name.contains(".so") + && !seen.contains(&name); + if stale_lib { + let _ = std::fs::remove_file(entry.path()); + } + } + + assert!( + staged_any, + "no sherpa/onnxruntime shared libs found in {} — build wisp-engine-sherpa first", + target_dir.display() + ); +} + +/// Upstream ships the Vulkan runtime (`vulkan-1.dll` + license) inside the Windows installer — +/// see `scripts/stage-vulkan-loader.sh` — so the whisper.cpp Vulkan build launches even with no +/// Vulkan loader installed; the loader then discovers whatever ICD the GPU driver provides. +/// Mirror that here: with `whisper-vulkan` enabled the binary has a hard `DT_NEEDED` on +/// `libvulkan.so.1`, and the loader is a separate package (`libvulkan1`/`vulkan-loader`), not part +/// of the GPU driver — absent on minimal installs it would stop the app before `main`, making +/// ggml's CPU fallback unreachable. Stage the build host's loader next to the sherpa libs so the +/// `$ORIGIN/../lib/Wisp` rpath always finds one; a bundled loader still loads the host's ICDs. +#[cfg(target_os = "linux")] +fn stage_linux_vulkan_loader() { + use std::path::Path; + + if std::env::var_os("CARGO_FEATURE_WHISPER_VULKAN").is_none() { + return; + } + let staged = Path::new(env!("CARGO_MANIFEST_DIR")).join("linux-runtime"); + for dir in [ + "/usr/lib/x86_64-linux-gnu", + "/lib/x86_64-linux-gnu", + "/usr/lib64", + "/usr/lib", + ] { + let src = Path::new(dir).join("libvulkan.so.1"); + if !src.is_file() { + continue; + } + // Resolve the soname symlink and stage the real loader under the NEEDED name. + let real = std::fs::canonicalize(&src).unwrap_or(src); + let dst = staged.join("libvulkan.so.1"); + let stale = dst + .metadata() + .map(|m| m.len() != real.metadata().map(|s| s.len()).unwrap_or(0)) + .unwrap_or(true); + if stale { + std::fs::copy(&real, &dst).unwrap_or_else(|e| panic!("stage {}: {e}", real.display())); + } + std::fs::write( + staged.join("VULKAN_LOADER_LICENSE.txt"), + "The bundled libvulkan.so.1 is the Khronos Vulkan loader (Vulkan-Loader),\n\ + copyright the Khronos Group and contributors, licensed under Apache-2.0.\n\ + Source: https://github.com/KhronosGroup/Vulkan-Loader\n", + ) + .expect("write Vulkan loader attribution"); + return; + } + panic!( + "the whisper-vulkan build links libvulkan.so.1 but none was found on the build host — \ + install libvulkan1 or the LunarG vulkan-sdk so the loader can be bundled" + ); +} + +/// The cargo `target/` dir, found by walking up from `OUT_DIR` — the same place +/// sherpa-rs-sys copies its shared libs to. Same logic as the Windows `cargo_target_dir` below. +#[cfg(target_os = "linux")] +fn cargo_target_dir_linux() -> std::path::PathBuf { + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let profile = std::env::var("PROFILE").unwrap(); + let mut dir = out_dir.as_path(); + while let Some(parent) = dir.parent() { + if parent.ends_with(&profile) { + return parent.to_path_buf(); + } + dir = parent; + } + panic!( + "could not find target/{profile} above OUT_DIR {}", + out_dir.display() + ); +} + /// Copies the sherpa-onnx + onnxruntime runtime DLLs from the cargo target dir into /// `windows-runtime/` (next to this build script) so they can be bundled as Tauri resources. A /// size check skips the copy when already current, so the build script doesn't perpetually re-fire. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 17440f1..898c3aa 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use tauri::{path::BaseDirectory, AppHandle, Emitter, Manager, State}; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] use wisp_aec::WebrtcEchoCanceller; use wisp_audio::{ normalize_for_asr_in_place, tee, to_mono_16k, ChannelSource, EchoCancellingSource, MediaSource, @@ -57,6 +57,8 @@ use wisp_pipeline::{ remap_to_original, transcribe_in_windows, EnergySegmenter, EnergyVad, GatedClip, LiveStream, Segmenter, Session, Transcriber, Vad, DEFAULT_SILENCE_HANGOVER, }; +#[cfg(target_os = "linux")] +use wisp_pulseaudio::PulseMonitorSource; #[cfg(target_os = "macos")] use wisp_screencapture::ScreenCaptureSource; @@ -99,7 +101,8 @@ const FILE_PROGRESS_EVENT: &str = "file://progress"; const FILE_SEGMENT_EVENT: &str = "file://segment"; const FILE_DONE_EVENT: &str = "file://done"; -/// Sentinel "device" id selecting one-click system-audio capture (ScreenCaptureKit, no setup). +/// Sentinel "device" id selecting one-click system-audio capture — ScreenCaptureKit on macOS, +/// WASAPI loopback on Windows, the PulseAudio/PipeWire monitor on Linux; no setup on any of them. /// Must match the value used by the UI. const SYSTEM_CAPTURE_ID: &str = "__wisp_system_audio__"; @@ -611,11 +614,15 @@ fn coerce_param(raw: &serde_json::Value, kind: &ParamKind) -> Option } } -/// Builds the GPU whisper.cpp engine from a downloaded GGUF model — Metal on macOS, Vulkan on Windows -/// (under the `whisper-vulkan` feature). Where the engine isn't built, the stub below reports it. +/// Builds the GPU whisper.cpp engine from a downloaded GGUF model — Metal on macOS, Vulkan on +/// Windows and Linux (under the `whisper-vulkan` feature). Where the engine isn't built, the stub +/// below reports it. #[cfg(any( target_os = "macos", - all(target_os = "windows", feature = "whisper-vulkan") + all( + any(target_os = "windows", target_os = "linux"), + feature = "whisper-vulkan" + ) ))] fn build_whisper_cpp_engine( descriptor: &ModelDescriptor, @@ -634,7 +641,10 @@ fn build_whisper_cpp_engine( #[cfg(not(any( target_os = "macos", - all(target_os = "windows", feature = "whisper-vulkan") + all( + any(target_os = "windows", target_os = "linux"), + feature = "whisper-vulkan" + ) )))] fn build_whisper_cpp_engine( _descriptor: &ModelDescriptor, @@ -642,7 +652,7 @@ fn build_whisper_cpp_engine( _language: &str, ) -> WispResult> { Err(WispError::Engine( - "the whisper.cpp GPU engine is only available on macOS, and on Windows GPU builds" + "the whisper.cpp GPU engine is only available on macOS, and on Windows/Linux GPU builds" .to_owned(), )) } @@ -1111,9 +1121,10 @@ fn machine_ram_bytes() -> u64 { 16 * 1024 * 1024 * 1024 } -/// The best ASR accelerator on this host. macOS uses the Apple GPU via Metal (whisper.cpp); other -/// platforms run ONNX on the CPU today, so they report `Cpu` until a Windows/Linux GPU engine is -/// wired (then this grows to CUDA/Vulkan/DirectML and the recommender follows automatically). +/// The best ASR accelerator on this host. macOS uses the Apple GPU via Metal (whisper.cpp); the +/// Windows/Linux Vulkan builds can run whisper.cpp too (with a CPU fallback — see +/// `family_runnable`), but this still reports `Cpu` until real GPU probing lands, so the +/// recommender never auto-picks a GPU model off-Metal. #[cfg(target_os = "macos")] fn host_accelerator() -> Accelerator { Accelerator::Metal @@ -1163,8 +1174,9 @@ fn sysctl_string(name: &std::ffi::CStr) -> Option { /// The GPU power tier. On Apple Silicon it's the chip class parsed from the CPU brand string /// ("Apple M2 Max" → High, "… Ultra" → Ultra, "… Pro" → Standard, plain "Apple M_" → Entry), the -/// signal that decides which Whisper this Mac can run live. Other platforms report `None` until a -/// GPU ASR engine is wired for them. +/// signal that decides which Whisper this Mac can run live. Other platforms report `None` — the +/// Vulkan whisper.cpp builds don't probe the GPU tier, so the recommender stays conservative +/// off-Metal. #[cfg(target_os = "macos")] fn host_gpu_tier() -> GpuTier { let brand = sysctl_string(c"machdep.cpu.brand_string").unwrap_or_default(); @@ -1215,7 +1227,13 @@ fn to_model_info( let size_bytes = d.total_size_bytes(); let deletable = model_deletable(!d.files.is_empty(), store.local_path(&d.id).is_some()); - let coreml = coreml_asset(&d); + // Core ML only accelerates the whisper.cpp Metal engine, which exists on macOS alone — the + // Windows/Linux Vulkan builds must not advertise a ~1 GB encoder download they can't use. + let coreml = if cfg!(target_os = "macos") { + coreml_asset(&d) + } else { + None + }; let coreml_installed = coreml .as_ref() .is_some_and(|a| store.coreml_installed(&d.id, a)); @@ -2940,6 +2958,9 @@ async fn download_coreml( state: State<'_, AppState>, id: String, ) -> Result<(), String> { + if !cfg!(target_os = "macos") { + return Err("Core ML acceleration is only available on macOS".to_owned()); + } let store = Arc::clone(&state.store); let model_id = ModelId(id.clone()); let asset = state @@ -3162,8 +3183,9 @@ fn set_devices( } /// Opens the system-audio capture source for this platform, or an error so the caller degrades to -/// mic-only. macOS uses ScreenCaptureKit and Windows uses WASAPI loopback — both one-click, no -/// virtual device; other platforms report unavailable until their own is added. +/// mic-only. Each desktop platform has a one-click source — ScreenCaptureKit on macOS, WASAPI +/// loopback on Windows, the PulseAudio/PipeWire monitor on Linux; other platforms report +/// unavailable until their own is added. #[cfg(target_os = "macos")] fn open_system_capture() -> Result, String> { ScreenCaptureSource::new() @@ -3178,7 +3200,16 @@ fn open_system_capture() -> Result, String> { .map_err(|e| e.to_string()) } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +/// Linux captures the default output's monitor through the PulseAudio/PipeWire server — one +/// click, no virtual device, like ScreenCaptureKit and WASAPI loopback. +#[cfg(target_os = "linux")] +fn open_system_capture() -> Result, String> { + PulseMonitorSource::new() + .map(|s| Box::new(s) as Box) + .map_err(|e| e.to_string()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn open_system_capture() -> Result, String> { Err("system-audio capture isn't available on this platform yet".to_owned()) } @@ -3204,17 +3235,24 @@ fn open_mic_within(device: Option) -> Result, Strin }, ) .unwrap_or_else(|| { + let reset_hint = if cfg!(target_os = "linux") { + "systemctl --user restart pipewire pipewire-pulse" + } else if cfg!(target_os = "windows") { + "net stop audiosrv && net start audiosrv" + } else { + "sudo killall coreaudiod" + }; Err(format!( - "microphone didn't start within {MIC_STARTUP_TIMEOUT:?} — it may be held by another app or the audio system is wedged. Restart the app, or reset audio with: sudo killall coreaudiod" + "microphone didn't start within {MIC_STARTUP_TIMEOUT:?} — it may be held by another app or the audio system is wedged. Restart the app, or reset audio with: {reset_hint}" )) }) } -/// The echo canceller for this platform: WebRTC AEC on macOS (falling back to passthrough if it -/// won't init), passthrough elsewhere. Keeping it a `Box` lets the dual-stream -/// path stay identical on every platform — the cross-stream dedup handles residual echo where there -/// is no real AEC. -#[cfg(target_os = "macos")] +/// The echo canceller for this platform: WebRTC AEC on macOS and Linux (falling back to +/// passthrough if it won't init), passthrough elsewhere. Keeping it a `Box` +/// lets the dual-stream path stay identical on every platform — the cross-stream dedup handles +/// residual echo where there is no real AEC. +#[cfg(any(target_os = "macos", target_os = "linux"))] fn echo_canceller() -> Box { match WebrtcEchoCanceller::new() { Ok(c) => Box::new(c), @@ -3225,7 +3263,7 @@ fn echo_canceller() -> Box { } } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn echo_canceller() -> Box { Box::new(PassthroughEchoCanceller) } @@ -3439,9 +3477,10 @@ fn start_session_blocking(app: AppHandle, options: LiveOptions) -> Result