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

@ -1,20 +1,26 @@
use mlua::{Lua, Table};
use std::process::{Command, Stdio};
use crate::text_utils::sanitize_for_speech;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
// jarvis.speak(text, opts?)
// opts.lang: ISO 2-letter code (ru, en, de, ...). default "ru"
// opts.async: if true, fire-and-forget; if false, block until done. default true
// opts.raw: if true, skip text sanitization (default false)
let speak_fn = lua.create_function(|_, (text, opts): (String, Option<Table>)| {
let mut iso = "ru".to_string();
let mut detached = true;
let mut raw = false;
if let Some(t) = opts {
if let Ok(v) = t.get::<String>("lang") { iso = v; }
if let Ok(v) = t.get::<bool>("async") { detached = v; }
if let Ok(v) = t.get::<String>("lang") { iso = v; }
if let Ok(v) = t.get::<bool>("async") { detached = v; }
if let Ok(v) = t.get::<bool>("raw") { raw = v; }
}
speak_via_sapi(&text, &iso, detached);
let prepared = if raw { text } else { sanitize_for_speech(&text) };
speak_via_sapi(&prepared, &iso, detached);
Ok(())
})?;
jarvis.set("speak", speak_fn)?;
@ -61,3 +67,4 @@ fn speak_via_sapi(text: &str, iso: &str, detached: bool) {
fn speak_via_sapi(text: &str, _iso: &str, _detached: bool) {
log::info!("[Lua tts] would speak: {}", text);
}