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.rs124
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");