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:
parent
9911b6ec40
commit
80b54af1ee
4 changed files with 164 additions and 5 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub mod audio_buffer;
|
|||
#[cfg(feature = "lua")]
|
||||
pub mod lua;
|
||||
|
||||
pub mod text_utils;
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod llm;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
149
crates/jarvis-core/src/text_utils.rs
Normal file
149
crates/jarvis-core/src/text_utils.rs
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue