From 5c65603df77be1492e125abe7fc49142b1d67d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 04:35:25 +0000 Subject: [PATCH] Enable Voice Input on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine was already cross-platform — cpal + whisper-rs, no per-target code anywhere in stt/ — so nothing had to be ported. Linux simply reported itself incompatible and the mic button stayed dead. Verified working end to end on an arm64 Chromebook (ChromeOS Crostini, Debian 12): whisper.cpp builds on aarch64, cpal opens the device, and whisper transcribes locally. Three changes: 1. Linux compatibility now asks the machine instead of returning a constant. macOS gates on Apple Silicon + 12 and Windows on x64 + build 19045; Linux has no equivalent version gate, so the real question is whether a capture device exists. Without this a machine with no microphone passes setup, downloads the 141 MB model, and fails only when the user presses record. Note the obvious probe is NOT enough: `default_input_device()` returns Some on a box with no capture hardware at all, because ALSA always presents a `default` PCM — observed on a container that still reported "compatible". `default_input_config()` is what touches the device, and it is the same call `start_recording()` makes, so a passing probe means a working stream. 2. whisper.cpp and GGML log to stderr by default, including per-token decoder output — a running transcript of what was just spoken. The Settings panel promises recordings and transcripts stay on the device, so letting them reach a terminal or the session journal contradicts the feature's own claim. `install_logging_hooks()` routes them into whisper-rs, where with neither the log_backend nor tracing_backend feature enabled they go nowhere. The FullParams print_* flags already set here never covered this: they gate whisper's result printing, not the library's internal logging. print_timestamps(false) added too — the one print_* flag that was missed. 3. The no-device error said "Check your Mac sound settings", which is reachable from Linux and Windows. Building the shell on Linux with voice additionally needs libasound2-dev, cmake and libclang-dev (cpal's headers; whisper-rs compiles whisper.cpp and generates bindings). Known limitation: when the probe finds no usable device, ALSA prints its own diagnostics to stderr. Silencing them needs snd_lib_error_set_handler through FFI, which cpal does not expose — unsafe code and a new dependency for cosmetic noise on machines that cannot use Voice Input anyway. Prior art: hughsheehy demonstrated Linux voice input first, in a fork release built from their linux-port branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF --- stt/src/lib.rs | 31 +++++++++++++++++- surfaces/gui/src-tauri/src/lib.rs | 53 ++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/stt/src/lib.rs b/stt/src/lib.rs index 5d1c2b98f3..f686ff3cf5 100644 --- a/stt/src/lib.rs +++ b/stt/src/lib.rs @@ -455,11 +455,29 @@ fn capture_worker( } } +/// The default input device's name, or None when the system has no capture device. +/// +/// Hosts use this to decide whether to offer Voice Input at all. Without it a machine with no +/// microphone still reports itself compatible, invites a 141 MB model download, and only fails +/// at the moment the user presses record — which is the worst place to find out. +/// +/// Probed live rather than cached so a microphone plugged in after launch is picked up. +pub fn input_device_name() -> Option { + let device = cpal::default_host().default_input_device()?; + // Existence is not enough. ALSA always presents a `default` PCM, so on Linux + // `default_input_device()` returns Some on a machine with no capture hardware at all. + // Querying the config is what actually touches the device, and it is the same call + // `start_recording` makes — so if this succeeds, opening the stream will too. + device.default_input_config().ok()?; + // A device that cannot report a name is still a device; fall back rather than hide it. + Some(device.name().unwrap_or_else(|_| "default input".to_owned())) +} + fn start_recording() -> Result { let host = cpal::default_host(); let device = host .default_input_device() - .ok_or_else(|| "No microphone is available. Check your Mac sound settings.".to_owned())?; + .ok_or_else(|| "No microphone is available. Check your system sound settings.".to_owned())?; let supported = device .default_input_config() .map_err(|e| format!("Could not open the microphone: {e}"))?; @@ -578,6 +596,16 @@ fn transcribe(model_path: &Path, samples: &[f32]) -> Result { if !model_path.is_file() { return Err("The local voice model is not installed yet.".to_owned()); } + // whisper.cpp and GGML log to stderr by default — including per-token decoder output, i.e. + // a running transcript of what the user just said. The app promises recordings and + // transcripts stay on the device; letting them leak into a terminal or the session journal + // contradicts that, and it is noise besides. This redirects those logs into whisper-rs's + // hooks, and with neither its `log_backend` nor `tracing_backend` feature enabled they go + // nowhere. `Once`-guarded upstream, so calling it per transcription is free. + // (The FullParams print_* flags below do NOT cover this: they gate whisper's own result + // printing, not the library's internal logging.) + whisper_rs::install_logging_hooks(); + let context = WhisperContext::new_with_params( model_path .to_str() @@ -594,6 +622,7 @@ fn transcribe(model_path: &Path, samples: &[f32]) -> Result { params.set_print_progress(false); params.set_print_special(false); params.set_print_realtime(false); + params.set_print_timestamps(false); params.set_suppress_blank(true); state .full(params, samples) diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index d96c3c6f4c..80309cb632 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -491,11 +491,23 @@ fn voice_input_compatibility() -> (bool, String, Option) { #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn voice_input_compatibility() -> (bool, String, Option) { - ( - false, - format!("{} · {}", std::env::consts::OS, std::env::consts::ARCH), - Some("Voice Input is currently supported on macOS and Windows.".to_owned()), - ) + // The engine is already cross-platform — cpal + whisper-rs, no per-target code — so on + // Linux the only real question is whether this machine can capture audio. There is no + // useful OS-version or CPU gate to apply the way macOS (Apple Silicon + 12) and Windows + // (x64 + 19045) have, so ask the device directly. + // + // Reporting a blanket "compatible" would walk a machine with no microphone through the + // whole setup flow, including the 141 MB model download, and fail only when the user + // finally presses record. + let arch = std::env::consts::ARCH; + match ocw_stt::input_device_name() { + Some(name) => (true, format!("Linux · {arch} · {name}"), None), + None => ( + false, + format!("Linux · {arch}"), + Some("No microphone was found. Connect one and reopen Settings.".to_owned()), + ), + } } #[tauri::command] @@ -894,3 +906,34 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Linux's answer must track the machine, not a constant. Exercises whichever branch the + /// test host is in: a box with a capture device proves the supported path, one without + /// proves the refusal — and CI runners, which have no microphone, are the latter. + #[cfg(target_os = "linux")] + #[test] + fn linux_voice_support_tracks_the_actual_capture_device() { + let (supported, _summary, reason) = voice_input_compatibility(); + assert_eq!( + supported, + ocw_stt::input_device_name().is_some(), + "Linux compatibility must reflect whether a capture device actually exists" + ); + assert_eq!(reason.is_some(), !supported, "refusals explain themselves"); + } + + /// Holds on every platform: Settings shows the summary beside the mic button, and an + /// unsupported platform that gives no reason leaves a dead control with no explanation. + #[test] + fn voice_input_compatibility_is_self_consistent() { + let (supported, summary, reason) = voice_input_compatibility(); + assert!(!summary.is_empty(), "Settings shows this summary"); + if !supported { + assert!(reason.is_some(), "an unsupported platform must say why"); + } + } +}