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:
parent
69a38b0d89
commit
3d127f05c0
1 changed files with 38 additions and 4 deletions
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue