diff options
Diffstat (limited to 'crates/asist-audio/src')
| -rw-r--r-- | crates/asist-audio/src/capture.rs | 48 | ||||
| -rw-r--r-- | crates/asist-audio/src/lib.rs | 58 | ||||
| -rw-r--r-- | crates/asist-audio/src/playback.rs | 117 | ||||
| -rw-r--r-- | crates/asist-audio/src/vad.rs | 130 |
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] - fn en_media_duplex_el_altavoz_no_se_transcribe_a_si_mismo() { + fn in_half_duplex_the_speaker_is_not_transcribed() { let mut seg = Segmenter::new(&config()); feed(&mut seg, 1.0, 0.0002); seg.set_gate(Gate::Speaking); let events = feed(&mut seg, 2.0, 0.5); assert!( events.is_empty(), - "con barge_in apagado no debe entrar nada mientras habla el asistente: {events:?}" + "with barge_in off nothing may come in while the assistant talks: {events:?}" ); } #[test] - fn con_barge_in_hace_falta_hablar_mas_alto_para_cortar() { + fn with_barge_in_you_must_speak_louder_to_interrupt() { let mut config = config(); config.barge_in = true; config.barge_in_factor = 4.0; @@ -343,24 +343,24 @@ mod tests { feed(&mut seg, 1.0, 0.0002); seg.set_gate(Gate::Speaking); - // Justo por encima del umbral normal pero por debajo del elevado. + // Just above the normal threshold but below the raised one. let eco = seg.threshold() / config.barge_in_factor * 1.5; let events = feed(&mut seg, 0.5, eco); assert!( events.is_empty(), - "el eco del altavoz no debe cortar: {events:?}" + "speaker echo must not interrupt: {events:?}" ); - let voz = seg.threshold() * 2.0; - let events = feed(&mut seg, 0.5, voz); + let speech = seg.threshold() * 2.0; + let events = feed(&mut seg, 0.5, speech); assert!( events.iter().any(|e| matches!(e, VoiceEvent::BargeIn)), - "una voz clara sí debe cortar: {events:?}" + "clear speech must interrupt: {events:?}" ); } #[test] - fn al_reabrir_el_microfono_se_tira_la_cola_d |