Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion stt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<Recording, String> {
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}"))?;
Expand Down Expand Up @@ -578,6 +596,16 @@ fn transcribe(model_path: &Path, samples: &[f32]) -> Result<String, String> {
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()
Expand All @@ -594,6 +622,7 @@ fn transcribe(model_path: &Path, samples: &[f32]) -> Result<String, String> {
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)
Expand Down
53 changes: 48 additions & 5 deletions surfaces/gui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,23 @@ fn voice_input_compatibility() -> (bool, String, Option<String>) {

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn voice_input_compatibility() -> (bool, String, Option<String>) {
(
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]
Expand Down Expand Up @@ -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");
}
}
}