From 3d127f05c0c4e5b160d20b9d17c3c17ec1a457b4 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 01:51:02 +0300 Subject: [PATCH] feat(app): speak LLM reply via Windows SAPI server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI front-end does not subscribe to IpcEvent::LlmReply, so the LLM auto-fallback was effectively silent — user got the OK chime but no spoken answer. Backlog item resolved. llm_fallback::handle now also invokes speak_via_sapi() after firing IpcEvent::LlmReply (both for successful replies and the fallback error message). The text goes through System.Speech.Synthesis. SpeechSynthesizer, with a preference pass that picks the first installed ru-RU voice if present (Microsoft Irina / Pavel etc.) so Russian replies sound right out of the box, and falls back to the default English voice otherwise. PowerShell is invoked fire-and-forget (no Wait, stdout/stderr null) so the voice loop never blocks. A long answer keeps speaking while jarvis returns to wake-word listening; the mic may pick up some of the speech which is the same compromise voices::play_* already makes. A cleaner version would pause pv_recorder for the duration of the utterance — that goes in the backlog as part of the eventual unified IpcEvent::Speak refactor. Toggle: set JARVIS_LLM_TTS=false to disable (e.g. on a server where the GUI is the speaker). Non-Windows builds get a no-op stub. --- crates/jarvis-app/src/llm_fallback.rs | 42 ++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs index 4d8a9f6..2c38ea9 100644 --- a/crates/jarvis-app/src/llm_fallback.rs +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -98,16 +98,50 @@ pub fn handle(prompt: &str) { let reply = reply.trim().to_string(); info!("LLM reply: {}", reply); state.history.lock().push_assistant(reply.clone()); - ipc::send(IpcEvent::LlmReply { text: reply }); + ipc::send(IpcEvent::LlmReply { text: reply.clone() }); voices::play_ok(); + speak_via_sapi(&reply); } Err(e) => { error!("LLM request failed: {}", e); state.history.lock().pop_last_user(); - ipc::send(IpcEvent::LlmReply { - text: config::LLM_FALLBACK_ERROR_RU.to_string(), - }); + let err_text = config::LLM_FALLBACK_ERROR_RU.to_string(); + ipc::send(IpcEvent::LlmReply { text: err_text.clone() }); voices::play_error(); + speak_via_sapi(&err_text); } } } + +#[cfg(target_os = "windows")] +fn speak_via_sapi(text: &str) { + if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") { + return; + } + let escaped = text.replace('\'', "''"); + let ps = format!( + "Add-Type -AssemblyName System.Speech; \ + $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; \ + foreach ($v in $s.GetInstalledVoices()) {{ \ + if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') {{ \ + try {{ $s.SelectVoice($v.VoiceInfo.Name); break }} catch {{ }} \ + }} \ + }} \ + $s.Speak('{}')", + escaped + ); + match std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &ps]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(_) => {} + Err(e) => warn!("SAPI spawn failed: {}", e), + } +} + +#[cfg(not(target_os = "windows"))] +fn speak_via_sapi(_text: &str) { + // No-op on non-Windows; LLM reply still arrives via IPC for the GUI to render. +}