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); + } +}