aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/text.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-core/src/text.rs')
-rw-r--r--crates/asist-core/src/text.rs330
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());
+ }
+}