diff options
| author | elvis <elvis@claros.ar> | 2026-09-06 19:21:16 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-06 19:23:37 -0300 |
| commit | f8f98e83481e7376a235fb305a095552433f79ab (patch) | |
| tree | 6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-core/src/text.rs | |
| download | asist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip | |
Local voice assistant on top of Canary, llama.cpp and qwentts
Pipeline en Rust de hilos y canales que une los tres motores: el micrófono
alimenta un segmentador con VAD, las intervenciones cerradas van al
reconocedor, la transcripción al modelo y cada frase que este cierra sale
hacia el sintetizador sin esperar al resto de la respuesta.
Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va
sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por
los canales lleva el turno al que pertenece, así que interrumpir es subir el
contador y levantar dos banderas de cancelación.
Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas),
audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de
procesos y orquestador). Los motores van como submódulos fijados a un commit,
con los cambios locales en vendor/patches.
Midiendo el pipeline aparecieron tres cuellos de botella de configuración que
valieron más que cualquier cambio de código, todos documentados en
docs/RENDIMIENTO.md:
- tts-server decodificaba el audio en bloques de 24 s, de modo que el modo
«streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio.
- La plantilla de chat del modelo abre <think> y no lo cierra nunca, sin
variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una
copia de la plantilla que deja el bloque cerrado de entrada.
- Cualquier indicación de estilo junto a la guía de herramientas hace que este
modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la
guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora
entre dos instrucciones de sistema.
La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro
barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que
interprete metacaracteres y plazo máximo.
68 pruebas unitarias sin modelos, más seis de integración que se saltan solas
si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no
caben en 4 GB y miden contención en vez de latencia.
Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'crates/asist-core/src/text.rs')
| -rw-r--r-- | crates/asist-core/src/text.rs | 330 |
1 files changed, 330 insertions, 0 deletions
diff --git a/crates/asist-core/src/text.rs b/crates/asist-core/src/text.rs new file mode 100644 index 0000000..e799561 --- /dev/null +++ b/crates/asist-core/src/text.rs @@ -0,0 +1,330 @@ +//! Preparación del texto que va a leerse en voz alta. +//! +//! Dos problemas distintos, ambos en el camino crítico de la latencia: +//! +//! 1. **Trocear en frases mientras el modelo escribe.** Esperar a la respuesta +//! completa antes de sintetizar suma el tiempo del LLM al del TTS. Cortando +//! por frases, el asistente empieza a hablar mientras sigue pensando. +//! 2. **Quitar lo que no se pronuncia.** El sintetizador lee los asteriscos y +//! las almohadillas tal cual, así que el markdown que se le escapa al modelo +//! hay que limpiarlo antes. + +/// Trocea un flujo de texto en frases pronunciables. +/// +/// La primera frase sale en cuanto es mínimamente decente y las siguientes +/// esperan a tener más cuerpo: el arranque manda en la latencia percibida, +/// pero una vez que suena la voz, las frases largas se entonan mejor. +#[derive(Debug)] +pub struct SentenceSplitter { + buffer: String, + emitted: usize, + /// Longitud mínima de la primera frase. + first_min: usize, + /// Longitud mínima de las siguientes. + rest_min: usize, + /// Longitud a partir de la cual se corta aunque no haya puntuación, para + /// que un modelo que no puntúa no deje al asistente mudo. + hard_max: usize, +} + +impl Default for SentenceSplitter { + fn default() -> Self { + Self { + buffer: String::new(), + emitted: 0, + first_min: 12, + rest_min: 40, + hard_max: 240, + } + } +} + +impl SentenceSplitter { + pub fn new() -> Self { + Self::default() + } + + pub fn with_limits(first_min: usize, rest_min: usize, hard_max: usize) -> Self { + Self { + first_min, + rest_min, + hard_max, + ..Self::default() + } + } + + pub fn emitted(&self) -> usize { + self.emitted + } + + /// Añade texto recién llegado y devuelve las frases que ya están completas. + pub fn push(&mut self, delta: &str) -> Vec<String> { + self.buffer.push_str(delta); + let mut out = Vec::new(); + while let Some(sentence) = self.take_ready() { + out.push(sentence); + } + out + } + + /// Entrega lo que quede al terminar la respuesta. + pub fn flush(&mut self) -> Option<String> { + let rest = clean_for_speech(&std::mem::take(&mut self.buffer)); + if !is_speakable(&rest) { + return None; + } + self.emitted += 1; + Some(rest) + } + + fn min_len(&self) -> usize { + if self.emitted == 0 { + self.first_min + } else { + self.rest_min + } + } + + fn take_ready(&mut self) -> Option<String> { + let cut = self.boundary()?; + let head: String = self.buffer.drain(..cut).collect(); + let head = clean_for_speech(&head); + if !is_speakable(&head) { + // Sólo era puntuación o markdown: se descarta sin gastar un turno + // de síntesis, pero el corte ya se ha consumido. + return self.take_ready(); + } + self.emitted += 1; + Some(head) + } + + /// Índice de byte por el que cortar, si hay alguno. + fn boundary(&self) -> Option<usize> { + let min = self.min_len(); + let mut last_soft = None; + + for (i, c) in self.buffer.char_indices() { + let end = i + c.len_utf8(); + if end < min { + continue; + } + if is_terminator(c) { + // El punto de una abreviatura o de un decimal no cierra frase. + if c == '.' && !ends_sentence(&self.buffer, i) { + continue; + } + // Sólo cierra si viene un espacio detrás, o si ya no queda + // nada: si no, se estaría cortando a mitad de «3.14». + match self.buffer[end..].chars().next() { + Some(next) if next.is_whitespace() => return Some(end), + None => {} + Some(_) => continue, + } + } + // Una coma o un punto y coma valen como corte de emergencia si la + // frase ya se ha pasado de largo. + if matches!(c, ',' | ';' | ':') { + last_soft = Some(end); + } + if end >= self.hard_max { + return Some(last_soft.unwrap_or(end)); + } + } + None + } +} + +/// ¿Hay algo que pronunciar? +/// +/// Un fragmento que sólo tiene puntuación —lo que queda al limpiar un «**» o +/// unos puntos suspensivos sueltos— cuesta una síntesis entera y no suena a +/// nada, así que no llega a salir del troceador. +pub fn is_speakable(text: &str) -> bool { + text.chars().any(char::is_alphanumeric) +} + +fn is_terminator(c: char) -> bool { + matches!(c, '.' | '!' | '?' | '…' | '\n') +} + +/// ¿El punto en `idx` cierra frase de verdad? +/// +/// Descarta decimales («3.14»), elipsis a medio escribir y las abreviaturas +/// más comunes en español, que si no parten la frase justo antes del nombre. +fn ends_sentence(text: &str, idx: usize) -> bool { + let before = &text[..idx]; + let after = &text[idx + 1..]; + + if after.starts_with(|c: char| c.is_ascii_digit()) + && before.ends_with(|c: char| c.is_ascii_digit()) + { + return false; + } + if after.starts_with('.') || before.ends_with('.') { + return false; + } + let word = before + .rsplit(|c: char| c.is_whitespace()) + .next() + .unwrap_or("") + .to_lowercase(); + const ABREVIATURAS: &[&str] = &[ + "sr", "sra", "srta", "dr", "dra", "ud", "uds", "etc", "ej", "p.ej", "av", "núm", "num", + "pág", "pag", "vol", "art", "ap", "aprox", "ee.uu", "d", "dña", + ]; + !ABREVIATURAS.contains(&word.as_str()) +} + +/// Deja el texto listo para el sintetizador. +/// +/// Se limita a quitar lo que no se pronuncia; no reescribe ni resume, porque +/// lo que suena tiene que ser lo que el modelo dijo. +pub fn clean_for_speech(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + let mut at_line_start = true; + + while let Some(c) = chars.next() { + match c { + // Énfasis y código: los delimitadores se leerían en voz alta. + '*' | '_' | '`' | '~' => continue, + // Encabezados y viñetas, sólo al principio de línea: una + // almohadilla o un guion en mitad de una frase sí significan algo. + '#' if at_line_start => { + while chars.peek() == Some(&'#') { + chars.next(); + } + while chars.peek().is_some_and(|c| *c == ' ') { + chars.next(); + } + continue; + } + '-' | '•' | '–' if at_line_start && chars.peek() == Some(&' ') => { + chars.next(); + continue; + } + '>' if at_line_start => { + while chars.peek().is_some_and(|c| *c == ' ') { + chars.next(); + } + continue; + } + // Los saltos de línea se hablan como pausas. + '\n' | '\r' | '\t' => { + at_line_start = c == '\n'; + if !out.ends_with(' ') && !out.is_empty() { + out.push(' '); + } + continue; + } + _ => {} + } + at_line_start = false; + out.push(c); + } + + // Colapsa los espacios que deja la limpieza. + let mut collapsed = String::with_capacity(out.len()); + let mut space = false; + for c in out.chars() { + if c == ' ' { + space = true; + continue; + } + if space && !collapsed.is_empty() { + collapsed.push(' '); + } + space = false; + collapsed.push(c); + } + collapsed.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn split_all(chunks: &[&str]) -> Vec<String> { + let mut splitter = SentenceSplitter::new(); + let mut out: Vec<String> = chunks.iter().flat_map(|c| splitter.push(c)).collect(); + out.extend(splitter.flush()); + out + } + + #[test] + fn la_primera_frase_sale_antes_que_las_siguientes() { + // La primera basta con que sea corta; la segunda espera a tener cuerpo. + let out = split_all(&["Claro que sí. ", "Vale. ", "Aún no.", ""]); + assert_eq!(out[0], "Claro que sí."); + assert_eq!(out[1], "Vale. Aún no."); + } + + #[test] + fn trocea_segun_llegan_los_deltas() { + let mut splitter = SentenceSplitter::new(); + assert!(splitter.push("Hola, ¿qué ").is_empty()); + assert!(splitter.push("tal est").is_empty()); + let out = splitter.push("ás hoy? "); + assert_eq!(out, vec!["Hola, ¿qué tal estás hoy?"]); + } + + #[test] + fn el_punto_decimal_no_parte_la_frase() { + let out = split_all(&["El resultado es 3.1416 exactamente y nada más."]); + assert_eq!(out.len(), 1, "un decimal no cierra frase: {out:?}"); + } + + #[test] + fn las_abreviaturas_no_parten_la_frase() { + let out = split_all(&["Avisa al Sr. Pérez cuanto antes por favor."]); + assert_eq!(out.len(), 1, "«Sr.» no cierra frase: {out:?}"); + } + + #[test] + fn una_parrafada_sin_puntuacion_se_corta_igualmente() { + // Sin este corte de emergencia, un modelo que no puntúa deja al + // asistente sin sintetizar nada hasta el final de la respuesta. + let largo = "palabra ".repeat(60); + let out = split_all(&[&largo]); + assert!(out.len() > 1, "esperaba varios cortes, hubo {}", out.len()); + assert!(out.iter().all(|s| s.len() <= 260)); + } + + #[test] + fn el_corte_de_emergencia_prefiere_una_coma() { + let mut splitter = SentenceSplitter::with_limits(4, 4, 40); + let out = splitter.push("uno dos tres, cuatro cinco seis siete ocho nueve diez once "); + assert_eq!(out[0], "uno dos tres,"); + } + + #[test] + fn se_limpia_el_markdown_que_se_leeria_en_voz_alta() { + assert_eq!(clean_for_speech("## Título"), "Título"); + assert_eq!( + clean_for_speech("Esto es **muy** importante"), + "Esto es muy importante" + ); + assert_eq!(clean_for_speech("- uno\n- dos"), "uno dos"); + assert_eq!(clean_for_speech("usa `ls -la` ahora"), "usa ls -la ahora"); + assert_eq!(clean_for_speech("> citado"), "citado"); + } + + #[test] + fn un_guion_dentro_de_la_frase_sobrevive() { + assert_eq!(clean_for_speech("teórico-práctico"), "teórico-práctico"); + } + + #[test] + fn los_fragmentos_solo_de_puntuacion_no_generan_sintesis() { + let out = split_all(&["**", "...", " "]); + assert!(out.is_empty(), "no había nada que pronunciar: {out:?}"); + } + + #[test] + fn el_flush_entrega_la_cola_sin_puntuacion_final() { + let mut splitter = SentenceSplitter::new(); + splitter.push("Una frase sin punto final"); + assert_eq!(splitter.flush().unwrap(), "Una frase sin punto final"); + assert!(splitter.flush().is_none()); + } +} |