From 80b54af1ee9e5a42f10fe413248c57426bc3f440 Mon Sep 17 00:00:00 2001
From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com>
Date: Fri, 15 May 2026 14:48:42 +0300
Subject: [PATCH] =?UTF-8?q?fix(tts):=20sanitise=20text=20before=20SAPI=20s?=
=?UTF-8?q?o=20it=20stops=20reading=20"J.A.R.V.I.S."=20as=20"J=20=D1=82?=
=?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=20A=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0?=
=?UTF-8?q?=20R=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0..."?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
crates/jarvis-app/src/llm_fallback.rs | 5 +-
crates/jarvis-core/src/lib.rs | 2 +
crates/jarvis-core/src/lua/api/tts.rs | 13 ++-
crates/jarvis-core/src/text_utils.rs | 149 ++++++++++++++++++++++++++
4 files changed, 164 insertions(+), 5 deletions(-)
create mode 100644 crates/jarvis-core/src/text_utils.rs
diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs
index 2c38ea9..7afbe18 100644
--- a/crates/jarvis-app/src/llm_fallback.rs
+++ b/crates/jarvis-app/src/llm_fallback.rs
@@ -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])
diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs
index 1b1968c..2b3f44b 100644
--- a/crates/jarvis-core/src/lib.rs
+++ b/crates/jarvis-core/src/lib.rs
@@ -48,6 +48,8 @@ pub mod audio_buffer;
#[cfg(feature = "lua")]
pub mod lua;
+pub mod text_utils;
+
#[cfg(feature = "llm")]
pub mod llm;
diff --git a/crates/jarvis-core/src/lua/api/tts.rs b/crates/jarvis-core/src/lua/api/tts.rs
index 43990f1..6c33257 100644
--- a/crates/jarvis-core/src/lua/api/tts.rs
+++ b/crates/jarvis-core/src/lua/api/tts.rs
@@ -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
)| {
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::("lang") { iso = v; }
- if let Ok(v) = t.get::("async") { detached = v; }
+ if let Ok(v) = t.get::("lang") { iso = v; }
+ if let Ok(v) = t.get::("async") { detached = v; }
+ if let Ok(v) = t.get::("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);
}
+
diff --git a/crates/jarvis-core/src/text_utils.rs b/crates/jarvis-core/src/text_utils.rs
new file mode 100644
index 0000000..cc5de15
--- /dev/null
+++ b/crates/jarvis-core/src/text_utils.rs
@@ -0,0 +1,149 @@
+// Public text helpers used by both the Lua tts API (`jarvis.speak`) and the
+// jarvis-app llm_fallback server-side TTS path. Kept here (not under `lua::api`)
+// so jarvis-app can depend on it without touching the lua sandbox internals.
+
+// Rewrite text so SAPI doesn't read every punctuation mark literally:
+// "J.A.R.V.I.S." → "Джарвис"
+// "T.O.N." / "U.S.A." → letters joined ("ТОН", "USA")
+// "https://google.com/foo?bar=1" → "ссылка"
+// "—" / "–" → "-"
+// collapses repeated whitespace, strips ASCII / Russian quotes.
+// Without this SAPI says "J ТОЧКА A ТОЧКА R ТОЧКА ..." which is unlistenable.
+pub fn sanitize_for_speech(text: &str) -> String {
+ let mut t = text.to_string();
+
+ // Specific brand/product names first (caught before generic acronym rule).
+ let replacements = [
+ ("J.A.R.V.I.S.", "Джарвис"),
+ ("J.A.R.V.I.S", "Джарвис"),
+ ("U.S.A.", "США"),
+ ("U.K.", "Британия"),
+ ("U.S.", "США"),
+ ("S.O.S.", "сос"),
+ ];
+ for (from, to) in replacements {
+ t = t.replace(from, to);
+ }
+
+ // Collapse generic dotted acronyms like "T.O.N." → "TON" (3+ letters,
+ // any alphabet) so SAPI reads the run as a single token instead of
+ // each letter-plus-точка.
+ t = collapse_dotted_acronyms(&t);
+
+ // Strip URLs. SAPI reads them character-by-character at ~50 words per
+ // minute; almost always the user wants a confirmation, not the URL.
+ t = strip_urls(&t);
+
+ // Soft punctuation cleanup.
+ t = t
+ .replace('—', " - ")
+ .replace('–', " - ")
+ .replace('«', "")
+ .replace('»', "")
+ .replace('"', "");
+
+ while t.contains(" ") {
+ t = t.replace(" ", " ");
+ }
+
+ t.trim().to_string()
+}
+
+fn collapse_dotted_acronyms(s: &str) -> String {
+ let chars: Vec = s.chars().collect();
+ let mut out: Vec = Vec::with_capacity(chars.len());
+ let mut i = 0;
+
+ while i < chars.len() {
+ let mut j = i;
+ let mut letters: Vec = Vec::new();
+ loop {
+ if j >= chars.len() { break; }
+ if !chars[j].is_alphabetic() { break; }
+ if j + 1 >= chars.len() || chars[j + 1] != '.' { break; }
+ letters.push(chars[j]);
+ j += 2;
+ }
+ if letters.len() >= 3 {
+ out.extend(letters.iter());
+ i = j;
+ } else {
+ out.push(chars[i]);
+ i += 1;
+ }
+ }
+ out.into_iter().collect()
+}
+
+fn strip_urls(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let mut chars = s.chars().peekable();
+ let mut url_buf = String::new();
+ let mut in_url = false;
+
+ while let Some(c) = chars.next() {
+ if !in_url {
+ if (c == 'h' || c == 'H')
+ && chars.clone().take(4).collect::().to_lowercase().starts_with("ttp")
+ {
+ in_url = true;
+ url_buf.clear();
+ url_buf.push(c);
+ continue;
+ }
+ out.push(c);
+ } else if c.is_whitespace() || c == ',' || c == ';' {
+ if url_buf.len() > 7 {
+ out.push_str("ссылка");
+ } else {
+ out.push_str(&url_buf);
+ }
+ out.push(c);
+ url_buf.clear();
+ in_url = false;
+ } else {
+ url_buf.push(c);
+ }
+ }
+ if in_url {
+ if url_buf.len() > 7 {
+ out.push_str("ссылка");
+ } else {
+ out.push_str(&url_buf);
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::sanitize_for_speech;
+
+ #[test]
+ fn jarvis_dotted_acronym() {
+ assert_eq!(sanitize_for_speech("Привет от J.A.R.V.I.S."), "Привет от Джарвис");
+ }
+
+ #[test]
+ fn generic_acronym_collapse() {
+ assert_eq!(sanitize_for_speech("Курс T.O.N. растёт"), "Курс TON растёт");
+ }
+
+ #[test]
+ fn url_replaced() {
+ let out = sanitize_for_speech("Открой https://www.google.com/search?q=x пожалуйста");
+ assert!(out.contains("ссылка"));
+ assert!(!out.contains("google.com"));
+ }
+
+ #[test]
+ fn dashes_normalised() {
+ assert_eq!(sanitize_for_speech("Тони — гений"), "Тони - гений");
+ }
+
+ #[test]
+ fn passes_clean_russian_unchanged() {
+ let s = "Привет, как дела сегодня";
+ assert_eq!(sanitize_for_speech(s), s);
+ }
+}