aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app/src/pipeline.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
committerelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
commit8518a63f55153e7f45fd49ad6caff5555f4e374f (patch)
tree636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-app/src/pipeline.rs
parent69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff)
downloadasist-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-app/src/pipeline.rs')
-rw-r--r--crates/asist-app/src/pipeline.rs176
1 files changed, 88 insertions, 88 deletions
diff --git a/crates/asist-app/src/pipeline.rs b/crates/asist-app/src/pipeline.rs
index 69fc66a..e8eef18 100644
--- a/crates/asist-app/src/pipeline.rs
+++ b/crates/asist-app/src/pipeline.rs
@@ -1,23 +1,23 @@
-//! El orquestador: los hilos del asistente y los canales que los unen.
+//! The orchestrator: the assistant threads and the channels that join them.
//!
//! ```text
-//! micrófono ──muestras──> segmentador ──intervención──> ASR ──texto──┐
-//! (cpal, tiempo real) (VAD, turnos) (Canary) │
-//! v
-//! altavoz <──muestras── síntesis <──frases── conversación <──────────┘
-//! (cpal, anillo) (qwentts) (llama.cpp + herramientas)
+//! microphone ──samples──> segmenter ──utterance──> ASR ──text──┐
+//! (cpal, real time) (VAD, turns) (Canary) │
+//! v
+//! speaker <──samples── synthesis <──sentences── conversation <─┘
+//! (cpal, ring) (qwentts) (llama.cpp + tools)
//! ```
//!
-//! Cada caja es un hilo y cada flecha un canal. Dos consecuencias que valen
-//! por todo el diseño:
+//! Each box is a thread and each arrow a channel. Two consequences carry the
+//! whole design:
//!
-//! * **Nada bloquea al que va delante.** El micrófono nunca espera al ASR, y
-//! el modelo nunca espera al sintetizador; quien va sobrado descarta trabajo
-//! en lugar de acumular retraso.
-//! * **Se responde por frases, no por respuestas.** La conversación entrega
-//! cada frase al sintetizador en cuanto está cerrada, así que el asistente
-//! empieza a hablar mientras el modelo sigue escribiendo. Es lo que separa
-//! una latencia de medio segundo de una de cinco.
+//! * **Nothing blocks the stage ahead.** The microphone never waits for the
+//! ASR, and the model never waits for the synthesizer; whoever has spare
+//! capacity drops work instead of accumulating delay.
+//! * **Answers go out per sentence, not per answer.** The conversation hands
+//! each sentence to the synthesizer as soon as it is closed, so the assistant
+//! starts speaking while the model is still writing. That is what separates
+//! half a second of latency from five.
use std::sync::Arc;
use std::thread::{self, JoinHandle};
@@ -41,17 +41,17 @@ use asist_tts::TtsClient;
use crate::session::Session;
-/// Una frase pendiente de sintetizar.
+/// A sentence waiting to be synthesized.
#[derive(Debug)]
pub struct SpeakJob {
pub turn: TurnId,
pub index: usize,
pub text: String,
- /// Origen desde el que se mide la latencia percibida del turno.
+ /// Origin from which the perceived latency of the turn is measured.
pub turn_started: Instant,
}
-/// Los hilos en marcha, para poder esperarlos al cerrar.
+/// The running threads, so they can be joined on shutdown.
pub struct Pipeline {
handles: Vec<JoinHandle<()>>,
pub events: Receiver<Event>,
@@ -61,7 +61,7 @@ pub struct Pipeline {
}
impl Pipeline {
- /// Monta el pipeline entero. `capture_rx` viene del micrófono.
+ /// Builds the whole pipeline. `capture_rx` comes from the microphone.
#[allow(clippy::too_many_arguments)]
pub fn spawn(
config: &Config,
@@ -78,15 +78,15 @@ impl Pipeline {
let (event_tx, event_rx) = unbounded::<Event>();
let (asr_tx, asr_rx) = unbounded::<AsrJob>();
let (result_tx, result_rx) = unbounded::<AsrResult>();
- // Acotado a propósito: si la síntesis se atasca, el hilo de
- // conversación debe notarlo y frenar en lugar de acumular frases que
- // llegarán tarde a un turno que quizá ya se ha interrumpido.
+ // Bounded on purpose: if synthesis gets stuck, the conversation thread
+ // must notice and slow down instead of piling up sentences that will
+ // arrive late to a turn that may already have been interrupted.
let (speak_tx, speak_rx) = bounded::<SpeakJob>(8);
let metrics = Arc::new(Metrics::default());
let mut handles = Vec::new();
- handles.push(spawn_named("segmentador", {
+ handles.push(spawn_named("segmenter", {
let config = config.clone();
let session = session.clone();
let events = event_tx.clone();
@@ -108,7 +108,7 @@ impl Pipeline {
recognizer.run(asr_rx, result_tx)
}));
- handles.push(spawn_named("conversación", {
+ handles.push(spawn_named("conversation", {
let config = config.clone();
let session = session.clone();
let events = event_tx.clone();
@@ -120,7 +120,7 @@ impl Pipeline {
}
}));
- handles.push(spawn_named("síntesis", {
+ handles.push(spawn_named("synthesis", {
let session = session.clone();
let events = event_tx;
move || run_speech(session, tts, speak_rx, playback, events)
@@ -135,8 +135,8 @@ impl Pipeline {
})
}
- /// Cierra en orden: se corta lo que suena, se suelta el emisor del
- /// micrófono y con eso la cadena de hilos se desmonta sola.
+ /// Shuts down in order: what is playing is cut, the microphone sender is
+ /// dropped, and with that the chain of threads takes itself apart.
pub fn shutdown(mut self) {
self.session.request_stop();
self.capture_tx.take();
@@ -150,11 +150,11 @@ fn spawn_named(name: &str, body: impl FnOnce() + Send + 'static) -> JoinHandle<(
thread::Builder::new()
.name(name.to_string())
.spawn(body)
- .expect("no se pudo crear un hilo del pipeline")
+ .expect("could not create a pipeline thread")
}
// ---------------------------------------------------------------------------
-// Segmentador: micrófono -> intervenciones
+// Segmenter: microphone -> utterances
// ---------------------------------------------------------------------------
fn run_segmenter(
@@ -173,27 +173,27 @@ fn run_segmenter(
if session.is_stopping() {
break;
}
- // El micrófono se cierra mientras suena el altavoz. Con barge-in
- // activo no se cierra, sólo sube el listón de volumen.
+ // The microphone is closed while the speaker is playing. With barge-in
+ // on it is not closed; only the volume bar goes up.
//
- // La segunda condición mira la cola y no una bandera a propósito: la
- // cola no puede quedarse desfasada, y una bandera que se olvide de
- // bajar deja el micrófono cerrado para el resto de la sesión.
+ // The second condition looks at the queue and not at a flag on purpose:
+ // the queue cannot fall out of sync, and a flag someone forgets to lower
+ // leaves the microphone closed for the rest of the session.
segmenter.set_gate(if session.is_speaking() || playback.queued_secs() > 0.0 {
Gate::Speaking
} else {
Gate::Open
});
- // El dispositivo se abre a 16 kHz mono siempre que puede, y entonces
- // esto no hace nada; cuando no puede, es aquí donde se convierte.
+ // The device is opened at 16 kHz mono whenever possible, and then this
+ // does nothing; when it cannot be, this is where it gets converted.
let mono = asist_audio::to_mono_at(&block.samples, input_format, ASR_SAMPLE_RATE);
for event in segmenter.push(&mono) {
match event {
VoiceEvent::BargeIn => {
- // Se corta antes de abrir el turno nuevo: así lo que
- // quedaba en el anillo no se cuela por encima.
+ // Cut before opening the new turn: that way whatever was left
+ // in the ring does not leak over it.
playback.stop();
session.interrupt();
let _ = events.send(Event::Interrupted {
@@ -239,7 +239,7 @@ fn run_segmenter(
}
}
}
- // Lo que quedara a medias al cerrar todavía merece transcribirse.
+ // Whatever was left half-done at shutdown still deserves a transcription.
if let Some(utterance) = segmenter.flush() {
let _ = asr.send(AsrJob::Utterance {
turn,
@@ -250,7 +250,7 @@ fn run_segmenter(
}
// ---------------------------------------------------------------------------
-// Conversación: transcripción -> frases
+// Conversation: transcription -> sentences
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
@@ -297,10 +297,10 @@ fn run_brain(
decode,
spoken_at,
} => {
- // Una transcripción de un turno superado llega tarde: el
- // usuario ya ha dicho otra cosa.
+ // A transcription from an outdated turn arrives too late: the
+ // user has already said something else.
if !session.is_current(turn) {
- tracing::debug!(target: "brain", turno = turn.0, "transcripción obsoleta");
+ tracing::debug!(target: "brain", turn = turn.0, "stale transcription");
continue;
}
let mut timer = TurnTimer::new(turn);
@@ -321,7 +321,7 @@ fn run_brain(
);
if config.general.report_latency {
- tracing::info!(target: "latencia", "\n{}", timer.report());
+ tracing::info!(target: "latency", "\n{}", timer.report());
}
metrics.record_turn(&timer);
}
@@ -329,26 +329,26 @@ fn run_brain(
}
}
-/// Las dos instrucciones de sistema entre las que alterna un turno.
+/// The two system prompts a turn alternates between.
///
-/// Que sean dos no es un capricho de diseño sino un resultado medido: con
-/// Qwen3.5-2B, cualquier indicación de estilo junto a la guía de herramientas
-/// hace que el modelo 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 añadida). Así que la
-/// pasada en que el modelo *decide* si actuar lleva la guía a solas, y la
-/// instrucción de voz se reserva para redactar lo que se va a pronunciar.
+/// Having two is not a design whim but a measured result: with Qwen3.5-2B,
+/// any style instruction next to the tool guide makes the model stop calling
+/// tools and make the data up (8/8 hits with the guide alone, 0/8 with the
+/// voice-assistant persona added). So the pass where the model *decides*
+/// whether to act carries the guide alone, and the voice prompt is kept for
+/// writing what will be spoken.
struct Prompts {
- /// Para la pasada en que el modelo decide si llamar a una herramienta.
+ /// For the pass where the model decides whether to call a tool.
deciding: String,
- /// Para redactar la respuesta hablada, ya con los resultados en la mano.
+ /// For writing the spoken answer, with the results already at hand.
///
- /// Lleva pegada la orden de usar lo que la herramienta devolvió. Sin ella
- /// el modelo se limita a anunciar lo que acaba de hacer —«he tomado una
- /// foto, ahora puedo responderte sobre el objeto o color»— y se deja el
- /// dato que tenía delante. Aquí sí se puede añadir estilo sin riesgo: la
- /// llamada ya ocurrió, así que no hay nada que estropear.
+ /// It carries the instruction to use what the tool returned. Without it the
+ /// model just announces what it did («he tomado una foto, ahora puedo
+ /// responderte sobre el objeto o color») and leaves out the data it had in
+ /// front of it. Style can be added safely here: the call already happened,
+ /// so there is nothing left to break.
speaking: String,
- /// `false` cuando no hay herramientas: entonces ambas son la misma.
+ /// `false` when there are no tools: then both are the same.
two_phase: bool,
}
@@ -372,8 +372,8 @@ impl Prompts {
}
}
-/// Genera la respuesta de un turno, resolviendo herramientas si el modelo las
-/// pide, y va soltando frases al sintetizador según se cierran.
+/// Generates the answer of a turn, resolving tools if the model asks for
+/// them, and releases sentences to the synthesizer as they close.
#[allow(clippy::too_many_arguments)]
fn answer(
config: &Config,
@@ -391,12 +391,12 @@ fn answer(
let tools = (!tools.is_empty() && config.tools.enabled).then_some(tools);
let mut splitter = SentenceSplitter::new();
let mut spoken = String::new();
- // Índice propio, y no el del troceador, porque al flujo de frases se le
- // cuelan los acuses de las herramientas. El índice 0 marca el primer
- // sonido del turno, que es de donde se mide la latencia percibida.
+ // Own index, not the chunker's, because tool acknowledgements slip into
+ // the sentence stream. Index 0 marks the first sound of the turn, which is
+ // where perceived latency is measured from.
let mut emitted = 0usize;
- // Manda una frase al sintetizador. Devuelve `false` si el canal se cerró.
+ // Sends a sentence to the synthesizer. Returns `false` if the channel closed.
let say = |index: &mut usize, text: String| -> bool {
let _ = events.send(Event::Sentence {
turn,
@@ -435,8 +435,8 @@ fn answer(
text: text.clone(),
});
spoken.push_str(text);
- // Aquí está el solape: cada frase cerrada sale hacia el
- // sintetizador sin esperar al resto de la respuesta.
+ // This is the overlap: every closed sentence goes to the
+ // synthesizer without waiting for the rest of the answer.
for sentence in splitter.push(text) {
if !say(&mut emitted, sentence) {
return false;
@@ -474,7 +474,7 @@ fn answer(
}
timer.sentences = emitted;
chat.push(Message::assistant(outcome.text.clone()));
- // El turno siguiente vuelve a empezar decidiendo.
+ // The next turn starts again by deciding.
if prompts.two_phase {
chat.set_system(&prompts.deciding);
}
@@ -485,20 +485,20 @@ fn answer(
return;
}
- // El modelo ha pedido herramientas: se ejecutan, se le devuelve el
- // resultado y se le vuelve a preguntar.
+ // The model asked for tools: they run, the result goes back to it and
+ // it is asked again.
if round == config.llm.max_tool_rounds {
let _ = events.send(Event::Warning {
turn,
message: format!(
- "el modelo siguió pidiendo herramientas tras {} vueltas; se corta",
+ "the model kept asking for tools after {} rounds; stopping",
config.llm.max_tool_rounds
),
});
return;
}
- let tools = tools.expect("no puede haber llamadas sin herramientas registradas");
+ let tools = tools.expect("there cannot be calls without registered tools");
chat.push(Message::tool_request(
outcome.text.clone(),
outcome.tool_calls.clone(),
@@ -510,9 +510,9 @@ fn answer(
name: call.name.clone(),
arguments: call.arguments.clone(),
});
- // Se dice antes de ejecutar, no después: la gracia es tapar la
- // espera, y una búsqueda con las dos pasadas del modelo detrás son
- // seis segundos que sin esto pasan en silencio absoluto.
+ // Said before running, not after: the point is to cover the
+ // wait, and a search with the two model passes behind it is
+ // six seconds that would otherwise pass in total silence.
if config.tools.spoken_ack {
if let Some(ack) = tools.get(&call.name).and_then(|t| t.acknowledgement()) {
say(&mut emitted, ack.to_string());
@@ -531,9 +531,9 @@ fn answer(
}
timer.record(Stage::Tools, tools_started.elapsed());
- // Con el resultado ya en la conversación, la siguiente vuelta sólo
- // tiene que redactar: es el momento de recuperar la instrucción de
- // voz, que en la pasada anterior habría impedido la llamada.
+ // With the result already in the conversation, the next round only
+ // has to write: this is when the voice prompt comes back, which in
+ // the previous pass would have prevented the call.
if prompts.two_phase {
chat.set_system(&prompts.speaking);
}
@@ -541,7 +541,7 @@ fn answer(
}
// ---------------------------------------------------------------------------
-// Síntesis: frases -> altavoz
+// Synthesis: sentences -> speaker
// ---------------------------------------------------------------------------
fn run_speech(
@@ -551,9 +551,9 @@ fn run_speech(
playback: PlaybackHandle,
events: Sender<Event>,
) {
- // Un `Select` en vez de `recv()` a secas para poder despertar
- // periódicamente y bajar la bandera de «hablando» cuando el anillo se
- // vacía: si no, el micrófono seguiría cerrado tras la última frase.
+ // A `Select` instead of a plain `recv()` so it can wake up periodically
+ // and lower the «speaking» flag when the ring empties: otherwise the
+ // microphone would stay closed after the last sentence.
let mut select = Select::new();
let job_index = select.recv(&jobs);
let mut speaking_turn: Option<TurnId> = None;
@@ -569,7 +569,7 @@ fn run_speech(
};
let Some(job) = job else {
- // Sin trabajo: si ya no queda audio, el turno ha terminado de sonar.
+ // No work: if there is no audio left, the turn has finished playing.
if let Some(turn) = speaking_turn {
if playback.queued_secs() <= 0.0 {
session.set_speaking(false);
@@ -584,9 +584,9 @@ fn run_speech(
continue;
};
- // Una frase de un turno ya superado no debe llegar a oírse.
+ // A sentence from an outdated turn must never be heard.
if !session.is_current(job.turn) {
- tracing::debug!(target: "tts", turno = job.turn.0, "frase obsoleta, descartada");
+ tracing::debug!(target: "tts", turn = job.turn.0, "stale sentence, dropped");
continue;
}
@@ -623,8 +623,8 @@ fn run_speech(
});
continue;
}
- // La cuenta del primer audio puede no haberse dado si el
- // anillo aún no había servido nada cuando llegó el bloque.
+ // The first-audio mark may not have been set if the ring had
+ // not played anything yet when the block arrived.
if first_of_turn && !announced {
let _ = events.send(Event::AudioStarted {
turn,
@@ -635,7 +635,7 @@ fn run_speech(
tracing::warn!(
target: "tts",
rtf = outcome.rtf(),
- "la síntesis va por detrás del tiempo real; la voz se cortará a trozos"
+ "synthesis is running behind real time; the voice will break up"
);
}
}