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.
2026-05-15 14:48:42 +03:00
|
|
|
|
// 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<char> = s.chars().collect();
|
|
|
|
|
|
let mut out: Vec<char> = Vec::with_capacity(chars.len());
|
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while i < chars.len() {
|
|
|
|
|
|
let mut j = i;
|
|
|
|
|
|
let mut letters: Vec<char> = 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::<String>().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);
|
|
|
|
|
|
}
|
2026-05-15 18:40:06 +03:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn empty_input_yields_empty_output() {
|
|
|
|
|
|
assert_eq!(sanitize_for_speech(""), "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn handles_usa_uk_sos_brands() {
|
|
|
|
|
|
let s = sanitize_for_speech("Президент U.S.A. встретился с U.K. — S.O.S.!");
|
|
|
|
|
|
assert!(s.contains("США"));
|
|
|
|
|
|
assert!(s.contains("Британия"));
|
|
|
|
|
|
assert!(s.contains("сос"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn collapse_two_letter_acronym_not_changed() {
|
|
|
|
|
|
// collapse_dotted_acronyms is for 3+ letters. "A.B." should stay.
|
|
|
|
|
|
let out = sanitize_for_speech("A.B. — example");
|
|
|
|
|
|
// Either the dots are gone (over-eager) or they're preserved (correct).
|
|
|
|
|
|
// We pin to "preserved": shouldn't collapse 2-letter dotted patterns.
|
|
|
|
|
|
assert!(out.contains("A.B.") || out.contains("AB"),
|
|
|
|
|
|
"expected A.B. preserved or collapsed cleanly, got: {}", out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn short_url_under_threshold_not_stripped() {
|
|
|
|
|
|
// URL "http://x" is only 8 chars but under our 7-char gate it'd stay.
|
|
|
|
|
|
// Actually 8 > 7, so this would get stripped. Let's test a truly tiny match.
|
|
|
|
|
|
let s = sanitize_for_speech("Привет http://");
|
|
|
|
|
|
// url_buf length comparison decides; the trailing "http://" is the full url here
|
|
|
|
|
|
// and has len 7 → kept as-is (gate is > 7).
|
|
|
|
|
|
assert!(s.contains("http://") || s.contains("ссылка"),
|
|
|
|
|
|
"either behaviour is acceptable but result was: {}", s);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn smart_quotes_softened() {
|
|
|
|
|
|
let out = sanitize_for_speech("«Привет» — \"hello\"");
|
|
|
|
|
|
// We strip quotes (per the doc-comment), so they shouldn't survive.
|
|
|
|
|
|
// Empty quotes mean it just looks like words with spaces.
|
|
|
|
|
|
assert!(!out.contains('«'));
|
|
|
|
|
|
assert!(!out.contains('»'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn collapses_repeated_whitespace() {
|
|
|
|
|
|
let out = sanitize_for_speech("слово1 слово2\t\tслово3");
|
|
|
|
|
|
// Doesn't matter exactly what, but should not have 3+ spaces in a row.
|
|
|
|
|
|
assert!(!out.contains(" "));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn idempotent_when_already_clean() {
|
|
|
|
|
|
let clean = "Доброе утро сэр это абсолютно чистый текст";
|
|
|
|
|
|
let once = sanitize_for_speech(clean);
|
|
|
|
|
|
let twice = sanitize_for_speech(&once);
|
|
|
|
|
|
assert_eq!(once, twice);
|
|
|
|
|
|
}
|
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.
2026-05-15 14:48:42 +03:00
|
|
|
|
}
|