fix(tts): sanitise text before SAPI so it stops reading "J.A.R.V.I.S." as "J точка A точка R точка..."

User complaint: SAPI reads every dotted acronym character-by-character with a
"точка" between letters. Unbearable on every speak. No real TTS analog
shipping yet (Silero/Coqui/Inworld in roadmap), so the immediate fix is text
preprocessing.

New module `jarvis-core::text_utils` with `sanitize_for_speech()`:
- Specific brand mapping: J.A.R.V.I.S. → Джарвис, U.S.A. → США, U.K. →
  Британия, U.S. → США, S.O.S. → сос.
- Generic dotted-acronym collapse: any `[letter].[letter].[letter].(...)`
  run of 3+ letters gets its dots stripped. "T.O.N." → "TON".
- URL stripping: anything starting with http/https gets replaced with the
  word "ссылка". SAPI reading URLs char-by-char is unlistenable.
- Em/en-dash normalisation: — → -, « » " stripped.
- Whitespace collapse.

Wired in two places:
- `jarvis.speak(text, opts?)` Lua API runs sanitiser unless opts.raw=true.
- `jarvis-app/llm_fallback::speak_via_sapi` runs sanitiser before the SAPI
  PowerShell shell-out for LLM auto-fallback replies.

5 unit tests in `text_utils::tests` covering jarvis-name collapse, generic
acronym pattern, URL stripping, dash normalisation, and "leave clean
russian sentences alone" — all green.

Real TTS upgrade (Silero v4 first cut) tracked as P0.1 in roadmap.
This commit is contained in:
Bossiara13 2026-05-15 14:48:42 +03:00
parent 9911b6ec40
commit 80b54af1ee
4 changed files with 164 additions and 5 deletions

View file

@ -118,7 +118,8 @@ fn speak_via_sapi(text: &str) {
if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") {
return;
}
let escaped = text.replace('\'', "''");
let cleaned = jarvis_core::text_utils::sanitize_for_speech(text);
let escaped = cleaned.replace('\'', "''");
let ps = format!(
"Add-Type -AssemblyName System.Speech; \
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; \
@ -128,7 +129,7 @@ fn speak_via_sapi(text: &str) {
}} \
}} \
$s.Speak('{}')",
escaped
escaped,
);
match std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps])