aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src
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-audio/src
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-audio/src')
-rw-r--r--crates/asist-audio/src/capture.rs48
-rw-r--r--crates/asist-audio/src/lib.rs58
-rw-r--r--crates/asist-audio/src/playback.rs117
-rw-r--r--crates/asist-audio/src/vad.rs130
4 files changed, 180 insertions, 173 deletions
diff --git a/crates/asist-audio/src/capture.rs b/crates/asist-audio/src/capture.rs
index 05da992..5e3a697 100644
--- a/crates/asist-audio/src/capture.rs
+++ b/crates/asist-audio/src/capture.rs
@@ -1,4 +1,4 @@
-//! Captura desde el micrófono.
+//! Microphone capture.
use std::time::Instant;
@@ -11,15 +11,15 @@ use asist_core::error::{Error, Result};
use crate::{describe, ASR_SAMPLE_RATE};
-/// Formato con el que se abrió realmente el dispositivo.
+/// Format the device was actually opened with.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InputFormat {
pub sample_rate: usize,
pub channels: usize,
}
-/// Un bloque tal y como sale de la retrollamada, con la marca de tiempo que
-/// permite luego medir cuánto se ha retrasado el pipeline respecto de la voz.
+/// A block as it comes out of the callback, with the timestamp that later
+/// lets us measure how far the pipeline lags behind the voice.
#[derive(Debug)]
pub struct CaptureBlock {
pub samples: Vec<f32>,
@@ -33,10 +33,10 @@ pub struct Capture {
}
impl Capture {
- /// Abre la entrada y empieza a empujar bloques por `tx`.
+ /// Opens the input and starts pushing blocks through `tx`.
///
- /// Prefiere 16 kHz mono porque es justo lo que quiere el modelo: si el
- /// dispositivo lo acepta, no hay remuestreo en ningún punto del camino.
+ /// It prefers 16 kHz mono because that is exactly what the model wants: if
+ /// the device accepts it, there is no resampling anywhere on the path.
pub fn open(config: &AudioConfig, tx: Sender<CaptureBlock>) -> Result<Self> {
let host = cpal::default_host();
let device = select_device(&host, &config.input_device)?;
@@ -48,11 +48,11 @@ impl Capture {
channels: supported.channels() as usize,
};
let stream_config: cpal::StreamConfig = supported.clone().into();
- let on_error = |err| tracing::error!(target: "audio", %err, "flujo de entrada");
+ let on_error = |err| tracing::error!(target: "audio", %err, "input stream");
- // Dentro de la retrollamada: convertir a f32, enviar y salir. `send`
- // sobre un canal sin límite no bloquea, que es la única propiedad que
- // aquí importa.
+ // Inside the callback: convert to f32, send and leave. `send` on an
+ // unbounded channel does not block, which is the only property that
+ // matters here.
macro_rules! build {
($sample:ty) => {
device
@@ -68,7 +68,7 @@ impl Capture {
on_error,
None,
)
- .map_err(|e| Error::Audio(format!("no se pudo abrir la entrada: {e}")))?
+ .map_err(|e| Error::Audio(format!("could not open the input: {e}")))?
};
}
@@ -78,20 +78,20 @@ impl Capture {
SampleFormat::U16 => build!(u16),
other => {
return Err(Error::Audio(format!(
- "formato de muestra no soportado: {other:?}"
+ "unsupported sample format: {other:?}"
)))
}
};
stream
.play()
- .map_err(|e| Error::Audio(format!("no se pudo arrancar la entrada: {e}")))?;
+ .map_err(|e| Error::Audio(format!("could not start the input: {e}")))?;
tracing::info!(
target: "audio",
- dispositivo = %device_name,
+ device = %device_name,
hz = format.sample_rate,
- canales = format.channels,
- remuestreo = format.sample_rate != ASR_SAMPLE_RATE as usize || format.channels != 1,
+ channels = format.channels,
+ resampling = format.sample_rate != ASR_SAMPLE_RATE as usize || format.channels != 1,
"entrada abierta"
);
@@ -102,8 +102,8 @@ impl Capture {
})
}
- /// Cierra el dispositivo. Al soltar el emisor, la cadena de hilos se
- /// desmonta sola de arriba abajo.
+ /// Closes the device. Dropping the sender makes the chain of threads
+ /// take itself apart from top to bottom.
pub fn stop(self) {
drop(self.stream);
}
@@ -113,12 +113,12 @@ fn select_device(host: &cpal::Host, wanted: &str) -> Result<cpal::Device> {
if wanted.is_empty() {
return host
.default_input_device()
- .ok_or_else(|| Error::Audio("no hay dispositivo de entrada".into()));
+ .ok_or_else(|| Error::Audio("no input device".into()));
}
let wanted_lower = wanted.to_lowercase();
let devices = host
.input_devices()
- .map_err(|e| Error::Audio(format!("no se pudieron listar las entradas: {e}")))?;
+ .map_err(|e| Error::Audio(format!("could not list the inputs: {e}")))?;
let mut seen = Vec::new();
for device in devices {
let name = describe(&device);
@@ -128,7 +128,7 @@ fn select_device(host: &cpal::Host, wanted: &str) -> Result<cpal::Device> {
seen.push(name);
}
Err(Error::Audio(format!(
- "ninguna entrada coincide con «{wanted}». Disponibles: {}",
+ "no input matches «{wanted}». Available: {}",
seen.join(", ")
)))
}
@@ -136,7 +136,7 @@ fn select_device(host: &cpal::Host, wanted: &str) -> Result<cpal::Device> {
fn preferred_config(device: &cpal::Device) -> Result<SupportedStreamConfig> {
let native = device
.supported_input_configs()
- .map_err(|e| Error::Audio(format!("no se pudo consultar la entrada: {e}")))?
+ .map_err(|e| Error::Audio(format!("could not query the input: {e}")))?
.filter(|range| range.channels() == 1)
.filter(|range| {
range.min_sample_rate() <= ASR_SAMPLE_RATE && ASR_SAMPLE_RATE <= range.max_sample_rate()
@@ -148,6 +148,6 @@ fn preferred_config(device: &cpal::Device) -> Result<SupportedStreamConfig> {
Some(config) => Ok(config),
None => device
.default_input_config()
- .map_err(|e| Error::Audio(format!("sin configuración de entrada: {e}"))),
+ .map_err(|e| Error::Audio(format!("no input configuration: {e}"))),
}
}
diff --git a/crates/asist-audio/src/lib.rs b/crates/asist-audio/src/lib.rs
index de3f002..a339d16 100644
--- a/crates/asist-audio/src/lib.rs
+++ b/crates/asist-audio/src/lib.rs
@@ -1,35 +1,35 @@
-//! Entrada y salida de audio, y el detector de voz que las separa en turnos.
+//! Audio input and output, and the voice detector that splits them into turns.
//!
-//! La regla que gobierna este crate: **la retrollamada de audio no bloquea
-//! nunca**. cpal la ejecuta en un hilo de tiempo real y cualquier espera ahí
-//! se oye como un chasquido, así que se limita a copiar muestras a un canal
-//! (entrada) o a vaciar un anillo ya rellenado (salida). Todo el trabajo real
-//! —remuestreo, VAD, HTTP— ocurre en hilos normales al otro lado.
+//! The rule that governs this crate: **the audio callback never blocks**. cpal
+//! runs it on a real-time thread and any wait there is heard as a click, so it
+//! only copies samples to a channel (input) or drains an already filled ring
+//! (output). All the real work (resampling, VAD, HTTP) happens on regular
+//! threads on the other side.
pub mod capture;
pub mod playback;
pub mod vad;
-/// Se reexporta para que el binario pueda listar dispositivos sin volver a
-/// declarar cpal y arriesgarse a resolver otra versión.
+/// Re-exported so the binary can list devices without declaring cpal
+/// again and risking resolving another version.
pub use cpal;
pub use capture::{Capture, CaptureBlock, InputFormat};
pub use playback::{detached_handle, Playback, PlaybackHandle};
pub use vad::{Gate, Segmenter, Utterance, VoiceEvent};
-/// Frecuencia a la que trabaja Canary. La captura se abre directamente aquí
-/// cuando el dispositivo lo permite, lo que quita el remuestreo del camino.
+/// Rate Canary works at. Capture is opened directly at this rate when the
+/// device allows it, which takes resampling off the path.
pub const ASR_SAMPLE_RATE: u32 = 16_000;
-/// Frecuencia a la que sintetiza qwentts.
+/// Rate qwentts synthesizes at.
pub const TTS_SAMPLE_RATE: u32 = 24_000;
-/// Nombre legible de un dispositivo.
+/// Human-readable device name.
///
-/// `DeviceTrait::name` está obsoleto en cpal 0.17 a favor de `description`,
-/// que devuelve una ficha entera; aquí sólo interesa el nombre, y tener un
-/// único sitio donde extraerlo evita repetir el desempaquetado.
+/// `DeviceTrait::name` is deprecated in cpal 0.17 in favour of `description`,
+/// which returns a whole record; only the name matters here, and having a
+/// single place to extract it avoids repeating the unwrapping.
pub fn describe(device: &impl cpal::traits::DeviceTrait) -> String {
device
.description()
@@ -37,7 +37,7 @@ pub fn describe(device: &impl cpal::traits::DeviceTrait) -> String {
.unwrap_or_else(|_| "desconocido".into())
}
-/// Nivel RMS de un bloque, la medida con la que el VAD decide.
+/// RMS level of a block, the measure the VAD decides on.
pub fn rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
@@ -46,11 +46,11 @@ pub fn rms(samples: &[f32]) -> f32 {
(sum / samples.len() as f32).sqrt()
}
-/// Mezcla a mono y remuestrea linealmente a `target`.
+/// Mixes down to mono and resamples linearly to `target`.
///
-/// La interpolación lineal basta: el dispositivo ya entrega la señal limitada
-/// en banda, y un remuestreador decente costaría más que la decodificación a
-/// la que alimenta.
+/// Linear interpolation is enough: the device already delivers a
+/// band-limited signal, and a decent resampler would cost more than the
+/// decoding it feeds.
pub fn to_mono_at(samples: &[f32], format: InputFormat, target: u32) -> Vec<f32> {
let mono: Vec<f32> = if format.channels > 1 {
samples
@@ -78,7 +78,7 @@ pub fn to_mono_at(samples: &[f32], format: InputFormat, target: u32) -> Vec<f32>
.collect()
}
-/// Convierte s16le a f32 en [-1, 1]. Es el formato en el que el TTS entrega.
+/// Converts s16le to f32 in [-1, 1]. It is the format the TTS delivers.
pub fn s16le_to_f32(bytes: &[u8], out: &mut Vec<f32>) {
for pair in bytes.chunks_exact(2) {
let sample = i16::from_le_bytes([pair[0], pair[1]]);
@@ -91,13 +91,13 @@ mod tests {
use super::*;
#[test]
- fn el_rms_de_una_senal_constante_es_su_amplitud() {
+ fn rms_of_a_constant_signal_is_its_amplitude() {
assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-6);
assert_eq!(rms(&[]), 0.0);
}
#[test]
- fn el_estereo_se_mezcla_a_mono_promediando() {
+ fn stereo_is_mixed_to_mono_by_averaging() {
let format = InputFormat {
sample_rate: 16_000,
channels: 2,
@@ -107,17 +107,17 @@ mod tests {
}
#[test]
- fn el_remuestreo_ajusta_la_duracion() {
+ fn resampling_adjusts_the_duration() {
let format = InputFormat {
sample_rate: 48_000,
channels: 1,
};
let out = to_mono_at(&vec![0.0; 4800], format, 16_000);
- assert_eq!(out.len(), 1600, "48 kHz -> 16 kHz debe dividir por tres");
+ assert_eq!(out.len(), 1600, "48 kHz -> 16 kHz must divide by three");
}
#[test]
- fn a_la_misma_frecuencia_el_remuestreo_no_toca_nada() {
+ fn resampling_at_the_same_rate_changes_nothing() {
let format = InputFormat {
sample_rate: 16_000,
channels: 1,
@@ -127,7 +127,7 @@ mod tests {
}
#[test]
- fn s16le_recorre_el_rango_completo() {
+ fn s16le_covers_the_full_range() {
let mut out = Vec::new();
s16le_to_f32(&[0x00, 0x00, 0xff, 0x7f, 0x00, 0x80], &mut out);
assert_eq!(out[0], 0.0);
@@ -136,13 +136,13 @@ mod tests {
}
#[test]
- fn un_byte_suelto_no_produce_una_muestra_a_medias() {
+ fn stray_byte_does_not_produce_a_partial_sample() {
let mut out = Vec::new();
s16le_to_f32(&[0x00, 0x00, 0x11], &mut out);
assert_eq!(
out.len(),
1,
- "el byte impar se ignora en vez de corromper la muestra"
+ "the odd byte is ignored instead of corrupting the sample"
);
}
}
diff --git a/crates/asist-audio/src/playback.rs b/crates/asist-audio/src/playback.rs
index 903519f..094d60a 100644
--- a/crates/asist-audio/src/playback.rs
+++ b/crates/asist-audio/src/playback.rs
@@ -1,10 +1,10 @@
-//! Reproducción del audio sintetizado.
+//! Playback of the synthesized audio.
//!
-//! El TTS produce a ráfagas —un bloque de codec cada vez— y el altavoz consume
-//! a ritmo constante, así que entre ambos hay un anillo. La retrollamada sólo
-//! vacía el anillo; quien sintetiza sólo lo llena. Cortar la respuesta es
-//! entonces una operación trivial y sin condiciones de carrera: se vacía el
-//! anillo y la voz calla en el siguiente bloque de audio.
+//! The TTS produces bursts (one codec block at a time) and the speaker consumes
+//! at a constant rate, so there is a ring between them. The callback only
+//! drains the ring; the synthesizer only fills it. Cutting the answer is then
+//! a trivial operation with no race conditions: the ring is emptied and the
+//! voice stops at the next audio block.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
@@ -18,15 +18,15 @@ use asist_core::error::{Error, Result};
use crate::{describe, to_mono_at, InputFormat, TTS_SAMPLE_RATE};
-/// Estado compartido entre quien sintetiza y la retrollamada del altavoz.
+/// State shared between the synthesizer and the speaker callback.
struct Ring {
samples: Mutex<std::collections::VecDeque<f32>>,
- /// Despierta a quien espera a que se vacíe la cola.
+ /// Wakes whoever is waiting for the queue to empty.
drained: Condvar,
- /// Muestras servidas al altavoz desde que arrancó. Sirve para saber si ya
- /// ha sonado algo de verdad, que es la latencia que percibe el usuario.
+ /// Samples served to the speaker since it started. Used to know whether
+ /// anything has really played yet, which is the latency the user perceives.
played: AtomicU64,
- /// Hay audio en curso (queda cola o se está alimentando).
+ /// There is audio in progress (queue left or still being fed).
active: AtomicBool,
gain: Mutex<f32>,
}
@@ -47,7 +47,7 @@ impl Ring {
}
}
-/// Mando a distancia del altavoz: se puede clonar y repartir por los hilos.
+/// Speaker remote control: it can be cloned and shared across threads.
#[derive(Clone)]
pub struct PlaybackHandle {
ring: Arc<Ring>,
@@ -55,7 +55,7 @@ pub struct PlaybackHandle {
}
impl PlaybackHandle {
- /// Encola muestras mono ya a la frecuencia del dispositivo.
+ /// Queues mono samples already at the device rate.
pub fn push(&self, samples: &[f32]) {
if samples.is_empty() {
return;
@@ -65,7 +65,7 @@ impl PlaybackHandle {
self.ring.active.store(true, Ordering::SeqCst);
}
- /// Encola audio del TTS (24 kHz mono), remuestreando si hace falta.
+ /// Queues TTS audio (24 kHz mono), resampling if needed.
pub fn push_tts(&self, samples: &[f32]) {
if self.sample_rate == TTS_SAMPLE_RATE {
self.push(samples);
@@ -78,13 +78,13 @@ impl PlaybackHandle {
self.push(&to_mono_at(samples, format, self.sample_rate));
}
- /// Marca que se ha terminado de alimentar audio y ya ha sonado todo.
+ /// Marks that feeding audio has finished and everything has played.
///
- /// Hace falta un método aparte de `stop`: éste no tira nada, sólo apaga la
- /// bandera de «hay audio en marcha». Sin él la bandera se quedaba puesta
- /// tras la primera respuesta, el segmentador mantenía el micrófono cerrado
- /// creyendo que el asistente seguía hablando, y el asistente no volvía a
- /// oír nada en toda la sesión.
+ /// It needs a method separate from `stop`: this one drops nothing, it only
+ /// clears the «audio in progress» flag. Without it the flag stayed set after
+ /// the first answer, the segmenter kept the microphone closed believing the
+ /// assistant was still talking, and the assistant never heard anything again
+ /// for the whole session.
pub fn mark_idle(&self) {
let queue = self.ring.lock();
if queue.is_empty() {
@@ -92,7 +92,7 @@ impl PlaybackHandle {
}
}
- /// Calla ahora mismo y tira lo que quedaba por sonar.
+ /// Goes silent right now and drops whatever was left to play.
pub fn stop(&self) {
let mut queue = self.ring.lock();
queue.clear();
@@ -100,12 +100,12 @@ impl PlaybackHandle {
self.ring.drained.notify_all();
}
- /// Segundos de audio pendientes de sonar.
+ /// Seconds of audio waiting to play.
pub fn queued_secs(&self) -> f32 {
self.ring.lock().len() as f32 / self.sample_rate as f32
}
- /// `true` si ya ha salido audio por el altavoz.
+ /// `true` if audio has already come out of the speaker.
pub fn has_played(&self) -> bool {
self.ring.played.load(Ordering::SeqCst) > 0
}
@@ -122,10 +122,10 @@ impl PlaybackHandle {
*self.ring.gain.lock().unwrap_or_else(|e| e.into_inner()) = gain;
}
- /// Espera a que se vacíe la cola, o a que venza el plazo.
+ /// Waits for the queue to empty, or for the deadline.
///
- /// Devuelve `true` si terminó de sonar todo y `false` si se agotó el
- /// tiempo, para que quien llama distinga «ya está» de «sigue sonando».
+ /// Returns `true` if everything finished playing and `false` if time ran
+ /// out, so the caller can tell «done» from «still playing».
pub fn wait_drained(&self, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
let mut queue = self.ring.lock();
@@ -149,8 +149,8 @@ impl PlaybackHandle {
}
}
-/// Un mando sin dispositivo detrás, para probar la lógica del anillo sin
-/// abrir una tarjeta de sonido.
+/// A handle with no device behind it, to test the ring logic without
+/// opening a sound card.
pub fn detached_handle(sample_rate: u32) -> PlaybackHandle {
PlaybackHandle {
ring: Arc::new(Ring::new(1.0)),
@@ -177,11 +177,11 @@ impl Playback {
let stream_config: cpal::StreamConfig = supported.clone().into();
let ring = Arc::new(Ring::new(config.output_gain));
- let on_error = |err| tracing::error!(target: "audio", %err, "flujo de salida");
+ let on_error = |err| tracing::error!(target: "audio", %err, "output stream");
- // La retrollamada: coger lo que haya, rellenar con silencio lo que
- // falte y salir. Nunca espera a que llegue audio, porque quedarse
- // esperando aquí se oye como un corte.
+ // The callback: take whatever there is, fill the rest with silence
+ // and leave. It never waits for audio, because waiting here is heard
+ // as a dropout.
macro_rules! build {
($sample:ty, $silence:expr, $convert:expr) => {{
let ring = Arc::clone(&ring);
@@ -196,7 +196,7 @@ impl Playback {
match queue.pop_front() {
Some(sample) => {
let value = (sample * gain).clamp(-1.0, 1.0);
- // Mono a N canales: la misma muestra en todos.
+ // Mono to N channels: the same sample on all of them.
for out in frame.iter_mut() {
*out = $convert(value);
}
@@ -219,7 +219,7 @@ impl Playback {
on_error,
None,
)
- .map_err(|e| Error::Audio(format!("no se pudo abrir la salida: {e}")))?
+ .map_err(|e| Error::Audio(format!("could not open the output: {e}")))?
}};
}
@@ -232,19 +232,19 @@ impl Playback {
}
other => {
return Err(Error::Audio(format!(
- "formato de salida no soportado: {other:?}"
+ "unsupported output format: {other:?}"
)))
}
};
stream
.play()
- .map_err(|e| Error::Audio(format!("no se pudo arrancar la salida: {e}")))?;
+ .map_err(|e| Error::Audio(format!("could not start the output: {e}")))?;
tracing::info!(
target: "audio",
- dispositivo = %device_name,
+ device = %device_name,
hz = sample_rate,
- canales = channels,
+ channels = channels,
"salida abierta"
);
@@ -270,12 +270,12 @@ fn select_device(host: &cpal::Host, wanted: &str) -> Result<cpal::Device> {
if wanted.is_empty() {
return host
.default_output_device()
- .ok_or_else(|| Error::Audio("no hay dispositivo de salida".into()));
+ .ok_or_else(|| Error::Audio("no output device".into()));
}
let wanted_lower = wanted.to_lowercase();
let devices = host
.output_devices()
- .map_err(|e| Error::Audio(format!("no se pudieron listar las salidas: {e}")))?;
+ .map_err(|e| Error::Audio(format!("could not list the outputs: {e}")))?;
let mut seen = Vec::new();
for device in devices {
let name = describe(&device);
@@ -285,16 +285,16 @@ fn select_device(host: &cpal::Host, wanted: &str) -> Result<cpal::Device> {
seen.push(name);
}
Err(Error::Audio(format!(
- "ninguna salida coincide con «{wanted}». Disponibles: {}",
+ "no output matches «{wanted}». Available: {}",
seen.join(", ")
)))
}
fn preferred_config(device: &cpal::Device) -> Result<cpal::SupportedStreamConfig> {
- // 24 kHz nativo evita remuestrear la síntesis; si no, se coge lo de fábrica.
+ // Native 24 kHz avoids resampling the synthesis; otherwise take the default.
let native = device
.supported_output_configs()
- .map_err(|e| Error::Audio(format!("no se pudo consultar la salida: {e}")))?
+ .map_err(|e| Error::Audio(format!("could not query the output: {e}")))?
.filter(|range| {
range.min_sample_rate() <= TTS_SAMPLE_RATE && TTS_SAMPLE_RATE <= range.max_sample_rate()
})
@@ -305,7 +305,7 @@ fn preferred_config(device: &cpal::Device) -> Result<cpal::SupportedStreamConfig
Some(config) => Ok(config),
None => device
.default_output_config()
- .map_err(|e| Error::Audio(format!("sin configuración de salida: {e}"))),
+ .map_err(|e| Error::Audio(format!("no output configuration: {e}"))),
}
}
@@ -314,12 +314,15 @@ mod tests {
use super::*;
#[test]
- fn el_anillo_deja_de_estar_activo_cuando_se_vacia() {
- // La regresión que dejaba mudo al asistente tras la primera respuesta:
- // sin marcar el fin, el segmentador creía que seguía hablando y no
- // volvía a abrir el micrófono nunca.
+ fn the_ring_stops_being_active_when_empty() {
+ // The regression that left the assistant mute after the first answer:
+ // without marking the end, the segmenter thought it was still talking
+ // and never reopened the microphone.
let handle = detached_handle(24_000);
- assert!(!handle.is_active(), "recién creado no hay nada sonando");
+ assert!(
+ !handle.is_active(),
+ "nothing is playing right after creation"
+ );
handle.push(&[0.1; 480]);
assert!(handle.is_active());
@@ -327,7 +330,7 @@ mod tests {
handle.mark_idle();
assert!(
handle.is_active(),
- "con audio pendiente todavía está sonando, no debe apagarse"
+ "with pending audio it is still playing and must not go inactive"
);
handle.stop();
@@ -336,12 +339,12 @@ mod tests {
handle.mark_idle();
assert!(
!handle.is_active(),
- "con la cola vacía debe quedar inactivo"
+ "with an empty queue it must become inactive"
);
}
#[test]
- fn la_cola_se_mide_en_segundos_de_audio() {
+ fn queue_is_measured_in_seconds_of_audio() {
let handle = detached_handle(24_000);
handle.push(&[0.0; 12_000]);
assert!((handle.queued_secs() - 0.5).abs() < 1e-6);
@@ -350,16 +353,20 @@ mod tests {
}
#[test]
- fn cortar_vacia_la_cola_al_instante() {
+ fn cutting_empties_the_queue_immediately() {
let handle = detached_handle(24_000);
handle.push(&[0.5; 48_000]);
handle.stop();
- assert_eq!(handle.queued_secs(), 0.0, "cortar debe tirar lo pendiente");
+ assert_eq!(
+ handle.queued_secs(),
+ 0.0,
+ "cutting must drop what is pending"
+ );
assert!(!handle.is_active());
}
#[test]
- fn el_audio_del_tts_se_remuestrea_al_dispositivo() {
+ fn tts_audio_is_resampled_to_the_device() {
// Medio segundo a 24 kHz debe seguir durando medio segundo a 48 kHz.
let handle = detached_handle(48_000);
handle.push_tts(&[0.0; 12_000]);
diff --git a/crates/asist-audio/src/vad.rs b/crates/asist-audio/src/vad.rs
index b30f833..24cf717 100644
--- a/crates/asist-audio/src/vad.rs
+++ b/crates/asist-audio/src/vad.rs
@@ -1,10 +1,10 @@
-//! Detección de voz y troceado en intervenciones.
+//! Voice activity detection and splitting into utterances.
//!
-//! Está escrito como una máquina de estados pura: se le dan muestras y
-//! devuelve eventos, sin hilos ni canales dentro. Así el comportamiento que
-//! más cuesta depurar a oído —cuándo arranca un turno, cuándo lo corta el
-//! silencio, cuándo se ignora el eco del propio altavoz— se puede probar
-//! entero con audio sintético y sin micrófono.
+//! It is written as a pure state machine: it is fed samples and returns
+//! events, with no threads or channels inside. That way the behaviour that is
+//! hardest to debug by ear (when a turn starts, when silence ends it, when
+//! the echo of the assistant's own speaker is ignored) can be fully tested
+//! with synthetic audio and no microphone.
use std::collections::VecDeque;
@@ -12,7 +12,7 @@ use asist_core::config::VadConfig;
use crate::{rms, ASR_SAMPLE_RATE};
-/// Una intervención cerrada, lista para transcribir.
+/// A closed utterance, ready to be transcribed.
#[derive(Debug, Clone)]
pub struct Utterance {
pub samples: Vec<f32>,
@@ -25,30 +25,30 @@ impl Utterance {
}
}
-/// Lo que el segmentador tiene que contar hacia fuera.
+/// What the segmenter reports to the outside.
#[derive(Debug, Clone)]
pub enum VoiceEvent {
/// Ha empezado a hablarse.
Started,
- /// Audio nuevo dentro de la intervención en curso, para las
- /// transcripciones provisionales.
+ /// New audio inside the current utterance, for the partial
+ /// transcriptions.
Audio(Vec<f32>),
- /// Intervención terminada y lo bastante larga como para transcribirla.
+ /// Utterance finished and long enough to be transcribed.
Ended(Utterance),
- /// Terminada pero demasiado corta: un golpe en la mesa, una tos.
+ /// Finished but too short: a knock on the table, a cough.
Discarded,
- /// Se ha detectado voz mientras el asistente hablaba, con el barge-in
- /// activo. El orquestador corta la reproducción al recibirlo.
+ /// Speech was detected while the assistant was talking, with barge-in
+ /// on. The orchestrator cuts playback when it gets this.
BargeIn,
}
-/// Qué hace el segmentador con el micrófono mientras suena el altavoz.
+/// What the segmenter does with the microphone while the speaker plays.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
- /// Nadie está hablando por el altavoz: se escucha con normalidad.
+ /// Nothing is playing on the speaker: listen normally.
Open,
- /// El asistente habla. Según la configuración, o se ignora la entrada
- /// (media dúplex) o se exige más volumen para interrumpir (barge-in).
+ /// The assistant is talking. Depending on the configuration, input is
+ /// either ignored (half duplex) or needs more volume to interrupt (barge-in).
Speaking,
}
@@ -63,10 +63,10 @@ pub struct Segmenter {
pending: Vec<f32>,
preroll: VecDeque<f32>,
utterance: Vec<f32>,
- /// Muestras de la intervención que estaban de verdad por encima del
- /// umbral. El mínimo se mide sobre esto y no sobre `utterance`, que
- /// arrastra el preroll: si no, 0,1 s de golpe en la mesa más 0,2 s de
- /// preroll pasan por una intervención válida y disparan un turno entero.
+ /// Samples of the utterance that really were above the threshold. The
+ /// minimum is measured on this and not on `utterance`, which carries the
+ /// preroll: otherwise 0.1 s of a knock on the table plus 0.2 s of preroll
+ /// passes for a valid utterance and triggers a whole turn.
voiced: usize,
noise_floor: f32,
silence_run: usize,
@@ -95,14 +95,14 @@ impl Segmenter {
}
}
- /// Abre o cierra el micrófono según hable o no el asistente.
+ /// Opens or closes the microphone depending on whether the assistant talks.
pub fn set_gate(&mut self, gate: Gate) {
if self.gate == gate {
return;
}
self.gate = gate;
- // Al volver a abrir tras una respuesta, lo acumulado es la cola del
- // propio altavoz: arrancar un turno con eso daría un turno fantasma.
+ // When reopening after an answer, what has accumulated is the tail of
+ // the assistant's own speaker: starting a turn with it would be a ghost turn.
if gate == Gate::Open {
self.pending.clear();
self.preroll.clear();
@@ -121,12 +121,12 @@ impl Segmenter {
self.noise_floor
}
- /// Umbral que separa voz de silencio ahora mismo.
+ /// Threshold that separates speech from silence right now.
pub fn threshold(&self) -> f32 {
let base = (self.noise_floor * self.config.threshold_factor)
.clamp(self.config.min_threshold, self.config.max_threshold);
- // Con el altavoz sonando hay que hablar más alto para colarse: el
- // micrófono se está oyendo a sí mismo.
+ // With the speaker playing you must speak louder to get through: the
+ // microphone is hearing itself.
if self.gate == Gate::Speaking {
base * self.config.barge_in_factor
} else {
@@ -134,7 +134,7 @@ impl Segmenter {
}
}
- /// Cierra a la fuerza la intervención en curso (cierre del programa).
+ /// Forcibly closes the current utterance (program shutdown).
pub fn flush(&mut self) -> Option<Utterance> {
if !self.speaking || self.voiced < self.min_utterance_samples {
return None;
@@ -147,11 +147,11 @@ impl Segmenter {
})
}
- /// Alimenta audio mono a 16 kHz y recoge lo que haya que hacer.
+ /// Feeds 16 kHz mono audio and collects whatever needs doing.
pub fn push(&mut self, samples: &[f32]) -> Vec<VoiceEvent> {
let mut events = Vec::new();
- // En media dúplex el micrófono está apagado de hecho: sin esto, el
- // asistente se transcribe a sí mismo y se responde solo.
+ // In half duplex the microphone is effectively off: without this, the
+ // assistant transcribes itself and answers itself.
if self.gate == Gate::Speaking && !self.config.barge_in {
return events;
}
@@ -211,8 +211,8 @@ impl Segmenter {
self.voiced = 0;
self.preroll.clear();
self.silence_run = 0;
- // Un corte por longitud cae a mitad de frase: se sigue escuchando
- // como si el usuario no hubiera dejado de hablar, que es la verdad.
+ // A length cut lands mid-sentence: keep listening as if the user had
+ // not stopped talking, which is the truth.
self.speaking = too_long && !ended;
if self.speaking {
events.push(VoiceEvent::Started);
@@ -221,8 +221,8 @@ impl Segmenter {
events
}
- /// El suelo de ruido sólo baja: una voz sostenida no debe poder arrastrar
- /// el umbral por encima de sí misma y dejar de detectarse.
+ /// The noise floor only goes down: sustained speech must not be able to
+ /// drag the threshold above itself and stop being detected.
fn track_noise_floor(&mut self, level: f32) {
if self.noise_floor == 0.0 {
self.noise_floor = level;
@@ -253,7 +253,7 @@ mod tests {
fn samples(secs: f32, amplitude: f32) -> Vec<f32> {
let n = (ASR_SAMPLE_RATE as f32 * secs) as usize;
- // Alterna de signo para que el RMS sea la amplitud y no un continuo.
+ // Alternates sign so the RMS is the amplitude and not a DC level.
(0..n)
.map(|i| if i % 2 == 0 { amplitude } else { -amplitude })
.collect()
@@ -264,19 +264,19 @@ mod tests {
}
#[test]
- fn el_silencio_no_arranca_ningun_turno() {
+ fn silence_starts_no_turn() {
let mut seg = Segmenter::new(&config());
let events = feed(&mut seg, 2.0, 0.0001);
assert!(
events.is_empty(),
- "el silencio no debe producir eventos: {events:?}"
+ "silence must produce no events: {events:?}"
);
}
#[test]
- fn la_voz_seguida_de_silencio_cierra_una_intervencion() {
+ fn speech_followed_by_silence_closes_an_utterance() {
let mut seg = Segmenter::new(&config());
- feed(&mut seg, 1.0, 0.0002); // deja que el suelo de ruido se asiente
+ feed(&mut seg, 1.0, 0.0002); // let the noise floor settle
let mut events = feed(&mut seg, 0.8, 0.3);
events.extend(feed(&mut seg, 0.6, 0.0002));
@@ -285,57 +285,57 @@ mod tests {
VoiceEvent::Ended(u) => Some(u),
_ => None,
});
- let utterance = ended.expect("la intervención debió cerrarse");
+ let utterance = ended.expect("the utterance should have closed");
assert!(
utterance.duration_secs() > 0.8,
- "el preroll debe ir incluido, duró {}",
+ "the preroll must be included, it lasted {}",
utterance.duration_secs()
);
}
#[test]
- fn un_ruido_corto_se_descarta() {
+ fn short_noise_is_discarded() {
let mut seg = Segmenter::new(&config());
feed(&mut seg, 1.0, 0.0002);
let mut events = feed(&mut seg, 0.1, 0.3);
events.extend(feed(&mut seg, 0.6, 0.0002));
- // 0,1 s de golpe no llegan al mínimo de 0,3 s de voz, por mucho que el
- // preroll haga que la intervención dure más.
+ // 0.1 s of a knock does not reach the 0.3 s speech minimum, however
+ // much the preroll makes the utterance last longer.
assert!(
events.iter().any(|e| matches!(e, VoiceEvent::Discarded)),
- "esperaba un descarte: {events:?}"
+ "expected a discard: {events:?}"
);
}
#[test]
- fn una_intervencion_interminable_se_corta_y_se_sigue_escuchando() {
+ fn endless_utterance_is_cut_and_listening_continues() {
let mut seg = Segmenter::new(&config());
feed(&mut seg, 1.0, 0.0002);
let events = feed(&mut seg, 3.0, 0.3);
assert!(
events.iter().any(|e| matches!(e, VoiceEvent::Ended(_))),
- "a los 2 s debe cortarse: {events:?}"
+ "it must be cut at 2 s: {events:?}"
);
assert!(
seg.is_speaking(),
- "tras un corte forzado se sigue en mitad de la frase"
+ "after a forced cut we are still mid-sentence"
);
}
#[test]