diff options
| author | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
| commit | 8518a63f55153e7f45fd49ad6caff5555f4e374f (patch) | |
| tree | 636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-core/src/text.rs | |
| parent | 69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff) | |
| download | asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.tar.gz asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.zip | |
Translate code, comments, logs and terminal UI to English; add English README; rename scriptsHEADmain
Diffstat (limited to 'crates/asist-core/src/text.rs')
| -rw-r--r-- | crates/asist-core/src/text.rs | 124 |
1 files changed, 63 insertions, 61 deletions
diff --git a/crates/asist-core/src/text.rs b/crates/asist-core/src/text.rs index e799561..30eef9d 100644 --- a/crates/asist-core/src/text.rs +++ b/crates/asist-core/src/text.rs @@ -1,29 +1,30 @@ -//! Preparación del texto que va a leerse en voz alta. +//! Preparing the text that will be read aloud. //! -//! Dos problemas distintos, ambos en el camino crítico de la latencia: +//! Two different problems, both on the latency critical path: //! -//! 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. +//! 1. **Splitting into sentences while the model writes.** Waiting for the +//! whole answer before synthesizing adds the LLM time to the TTS time. +//! Cutting per sentence, the assistant starts speaking while still thinking. +//! 2. **Removing what is not pronounced.** The synthesizer reads asterisks and +//! hashes literally, so markdown that slips out of the model must be +//! cleaned first. -/// Trocea un flujo de texto en frases pronunciables. +/// Splits a text stream into speakable sentences. /// -/// 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. +/// The first sentence goes out as soon as it is minimally decent and the next +/// ones wait until they have more body: the start dominates perceived +/// latency, but once the voice is playing, longer sentences get better +/// intonation. #[derive(Debug)] pub struct SentenceSplitter { buffer: String, emitted: usize, - /// Longitud mínima de la primera frase. + /// Minimum length of the first sentence. first_min: usize, - /// Longitud mínima de las siguientes. + /// Minimum length of the following ones. 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. + /// Length after which the text is cut even without punctuation, so a + /// model that does not punctuate does not leave the assistant mute. hard_max: usize, } @@ -57,7 +58,7 @@ impl SentenceSplitter { self.emitted } - /// Añade texto recién llegado y devuelve las frases que ya están completas. + /// Adds newly arrived text and returns the sentences already complete. pub fn push(&mut self, delta: &str) -> Vec<String> { self.buffer.push_str(delta); let mut out = Vec::new(); @@ -67,7 +68,7 @@ impl SentenceSplitter { out } - /// Entrega lo que quede al terminar la respuesta. + /// Delivers whatever is left when the answer ends. pub fn flush(&mut self) -> Option<String> { let rest = clean_for_speech(&std::mem::take(&mut self.buffer)); if !is_speakable(&rest) { @@ -90,15 +91,15 @@ impl SentenceSplitter { 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. + // It was only punctuation or markdown: dropped without spending a + // synthesis turn, but the cut has been consumed. return self.take_ready(); } self.emitted += 1; Some(head) } - /// Índice de byte por el que cortar, si hay alguno. + /// Byte index to cut at, if any. fn boundary(&self) -> Option<usize> { let min = self.min_len(); let mut last_soft = None; @@ -109,20 +110,20 @@ impl SentenceSplitter { continue; } if is_terminator(c) { - // El punto de una abreviatura o de un decimal no cierra frase. + // The dot of an abbreviation or a decimal does not end a sentence. 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». + // It only closes if a space follows, or if nothing is left: + // otherwise it would cut in the middle of «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. + // A comma or semicolon counts as an emergency cut if the sentence + // has already run too long. if matches!(c, ',' | ';' | ':') { last_soft = Some(end); } @@ -134,11 +135,11 @@ impl SentenceSplitter { } } -/// ¿Hay algo que pronunciar? +/// Is there anything to pronounce? /// -/// 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. +/// A fragment with only punctuation (what is left after cleaning a «**» or a +/// stray ellipsis) costs a whole synthesis and sounds like nothing, so it +/// never leaves the splitter. pub fn is_speakable(text: &str) -> bool { text.chars().any(char::is_alphanumeric) } @@ -147,10 +148,11 @@ fn is_terminator(c: char) -> bool { matches!(c, '.' | '!' | '?' | '…' | '\n') } -/// ¿El punto en `idx` cierra frase de verdad? +/// Does the dot at `idx` really end a sentence? /// -/// 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. +/// Rules out decimals («3.14»), half-written ellipses and the most common +/// Spanish abbreviations, which would otherwise split the sentence right +/// before the name. fn ends_sentence(text: &str, idx: usize) -> bool { let before = &text[..idx]; let after = &text[idx + 1..]; @@ -168,17 +170,17 @@ fn ends_sentence(text: &str, idx: usize) -> bool { .next() .unwrap_or("") .to_lowercase(); - const ABREVIATURAS: &[&str] = &[ + const ABBREVIATIONS: &[&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()) + !ABBREVIATIONS.contains(&word.as_str()) } -/// Deja el texto listo para el sintetizador. +/// Gets the text ready for the synthesizer. /// -/// 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. +/// It only removes what is not pronounced; it neither rewrites nor +/// summarizes, because what is heard must be what the model said. pub fn clean_for_speech(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); @@ -186,10 +188,10 @@ pub fn clean_for_speech(text: &str) -> String { while let Some(c) = chars.next() { match c { - // Énfasis y código: los delimitadores se leerían en voz alta. + // Emphasis and code: the delimiters would be read aloud. '*' | '_' | '`' | '~' => 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. + // Headings and bullets, only at the start of a line: a hash or a + // dash in the middle of a sentence does mean something. '#' if at_line_start => { while chars.peek() == Some(&'#') { chars.next(); @@ -209,7 +211,7 @@ pub fn clean_for_speech(text: &str) -> String { } continue; } - // Los saltos de línea se hablan como pausas. + // Line breaks are spoken as pauses. '\n' | '\r' | '\t' => { at_line_start = c == '\n'; if !out.ends_with(' ') && !out.is_empty() { @@ -223,7 +225,7 @@ pub fn clean_for_speech(text: &str) -> String { out.push(c); } - // Colapsa los espacios que deja la limpieza. + // Collapses the spaces the cleanup leaves behind. let mut collapsed = String::with_capacity(out.len()); let mut space = false; for c in out.chars() { @@ -252,15 +254,15 @@ mod tests { } #[test] - fn la_primera_frase_sale_antes_que_las_siguientes() { - // La primera basta con que sea corta; la segunda espera a tener cuerpo. + fn first_sentence_comes_out_before_the_rest() { + // The first one only needs to be short; the second waits for body. 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() { + fn chunks_as_deltas_arrive() { let mut splitter = SentenceSplitter::new(); assert!(splitter.push("Hola, ¿qué ").is_empty()); assert!(splitter.push("tal est").is_empty()); @@ -269,36 +271,36 @@ mod tests { } #[test] - fn el_punto_decimal_no_parte_la_frase() { + fn decimal_point_does_not_split_the_sentence() { 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:?}"); + assert_eq!(out.len(), 1, "a decimal does not end a sentence: {out:?}"); } #[test] - fn las_abreviaturas_no_parten_la_frase() { + fn abbreviations_do_not_split_the_sentence() { let out = split_all(&["Avisa al Sr. Pérez cuanto antes por favor."]); - assert_eq!(out.len(), 1, "«Sr.» no cierra frase: {out:?}"); + assert_eq!(out.len(), 1, "«Sr.» does not end a sentence: {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]); + fn long_unpunctuated_text_is_still_cut() { + // Without this emergency cut, a model that does not punctuate leaves + // the assistant synthesizing nothing until the end of the answer. + let long_text = "palabra ".repeat(60); + let out = split_all(&[&long_text]); 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() { + fn emergency_cut_prefers_a_comma() { 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() { + fn markdown_that_would_be_read_aloud_is_removed() { assert_eq!(clean_for_speech("## Título"), "Título"); assert_eq!( clean_for_speech("Esto es **muy** importante"), @@ -310,18 +312,18 @@ mod tests { } #[test] - fn un_guion_dentro_de_la_frase_sobrevive() { + fn dash_inside_a_sentence_survives() { assert_eq!(clean_for_speech("teórico-práctico"), "teórico-práctico"); } #[test] - fn los_fragmentos_solo_de_puntuacion_no_generan_sintesis() { + fn punctuation_only_fragments_produce_no_synthesis() { let out = split_all(&["**", "...", " "]); - assert!(out.is_empty(), "no había nada que pronunciar: {out:?}"); + assert!(out.is_empty(), "there was nothing to pronounce: {out:?}"); } #[test] - fn el_flush_entrega_la_cola_sin_puntuacion_final() { + fn flush_delivers_the_tail_without_final_punctuation() { let mut splitter = SentenceSplitter::new(); splitter.push("Una frase sin punto final"); assert_eq!(splitter.flush().unwrap(), "Una frase sin punto final"); |