feat(app): speak LLM reply via Windows SAPI server-side

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.
This commit is contained in:
Bossiara13 2026-05-15 01:51:02 +03:00
parent 69a38b0d89
commit 3d127f05c0

View file

@ -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.
}