//! 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 { 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 { 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 { 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 { 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 { let mut splitter = SentenceSplitter::new(); let mut out: Vec = 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()); } }