diff --git a/src/openhuman/inference/voice/local_transcribe.rs b/src/openhuman/inference/voice/local_transcribe.rs index ecf6899dc..72b093ab5 100644 --- a/src/openhuman/inference/voice/local_transcribe.rs +++ b/src/openhuman/inference/voice/local_transcribe.rs @@ -100,8 +100,9 @@ pub struct WhisperTranscribeResult { /// consumes a file path, not stdin. /// 3. Spawn `whisper-cli -m -f [-l ]`, capture /// stdout, and clean up the temp file regardless of outcome. -/// 4. Return the trimmed transcript. Empty stdout is reported as an error -/// (whisper produced no output → almost always a model/file mismatch). +/// 4. Return the trimmed transcript. Empty stdout on a *successful* run is a +/// normal "no speech captured" outcome (silence / mic gap) and returns an +/// empty transcript, not an error — see [`interpret_whisper_output`]. /// /// **No model assets are embedded.** The model file is downloaded by the /// installer into the workspace; this function only locates the binary. @@ -236,19 +237,53 @@ pub async fn transcribe_whisper( output.stdout.len(), output.stderr.len() ); - if !output.status.success() { + interpret_whisper_output( + output.status.success(), + exit_code, + &output.stdout, + &output.stderr, + model_id, + ) +} + +/// Interpret a finished whisper-cli invocation into an RPC outcome. +/// +/// Split out from the spawn path so the exit-status / empty-transcript +/// branching is unit-testable without a real subprocess. +/// +/// Branch semantics: +/// - **Non-zero exit** → hard error. whisper-cli failed (bad model/file, +/// decode error) — a real failure worth reporting. +/// - **Exit 0 with empty stdout** → an empty transcript is a *normal* "no +/// speech captured" outcome: the user released push-to-talk without +/// speaking, recorded silence, or hit a mic gap. whisper-cli ran +/// successfully; there is nothing to act on. Returning an error here +/// mischaracterised silence as a failure and flooded Sentry +/// (TAURI-RUST-2S: 799 events / 23 users across every shipped release). +/// Return `Ok` with an empty transcript instead — the frontend +/// (`sttClient.ts`) and internal callers (`voice::always_on`, +/// `desktop_companion::pipeline`) already treat empty text as a benign skip. +/// - **Exit 0 with text** → the trimmed transcript. +fn interpret_whisper_output( + success: bool, + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + model_id: String, +) -> Result, String> { + if !success { return Err(format!( - "{LOG_PREFIX} whisper-cli failed (exit={:?}): {}", - exit_code, - String::from_utf8_lossy(&output.stderr).trim() + "{LOG_PREFIX} whisper-cli failed (exit={exit_code:?}): {}", + String::from_utf8_lossy(stderr).trim() )); } - let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let text = String::from_utf8_lossy(stdout).trim().to_string(); if text.is_empty() { - return Err(format!( - "{LOG_PREFIX} whisper-cli returned empty transcript (model={model_id})" - )); + debug!( + "{LOG_PREFIX} whisper-cli returned empty transcript (model={model_id}) — \ + no speech captured, returning empty result" + ); } Ok(RpcOutcome::single_log( @@ -297,6 +332,47 @@ mod tests { assert_eq!(mime_to_extension(Some("audio/flac")), "flac"); } + #[test] + fn empty_stdout_on_success_is_ok_empty_not_error() { + // TAURI-RUST-2S: whisper-cli exits 0 but produces no text when the + // recording held no speech (silence / mic gap). That is a normal + // outcome, not a failure — must be Ok with an empty transcript so the + // RPC boundary does not report it to Sentry. + let out = + interpret_whisper_output(true, Some(0), b"", b"", "whisper-large-v3-turbo".into()) + .expect("empty transcript on success must be Ok"); + assert_eq!(out.value.text, ""); + assert_eq!(out.value.model_id, "whisper-large-v3-turbo"); + } + + #[test] + fn whitespace_only_stdout_on_success_is_ok_empty() { + // Trimming reduces a whitespace-only run to empty — still a no-speech + // outcome, still Ok. + let out = interpret_whisper_output(true, Some(0), b" \n\t ", b"", "medium".into()) + .expect("whitespace-only transcript must be Ok"); + assert_eq!(out.value.text, ""); + } + + #[test] + fn text_stdout_on_success_returns_trimmed_transcript() { + let out = + interpret_whisper_output(true, Some(0), b" hello world \n", b"", "medium".into()) + .expect("transcript must be Ok"); + assert_eq!(out.value.text, "hello world"); + assert_eq!(out.value.model_id, "medium"); + } + + #[test] + fn nonzero_exit_is_error() { + // Real failures (bad model/file, decode error) keep erroring so they + // stay visible — only the success+empty case was demoted. + let err = interpret_whisper_output(false, Some(1), b"", b"bad model file", "medium".into()) + .expect_err("non-zero exit must be Err"); + assert!(err.contains("whisper-cli failed"), "got: {err}"); + assert!(err.contains("bad model file"), "got: {err}"); + } + #[test] fn mime_to_extension_falls_back_to_webm() { // Unknown / missing / unparseable inputs default to webm — covers