diff options
Diffstat (limited to 'crates/asist-audio/src/vad.rs')
| -rw-r--r-- | crates/asist-audio/src/vad.rs | 130 |
1 files changed, 65 insertions, 65 deletions
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_del_altavoz() { + fn reopening_the_microphone_drops_the_speaker_tail() { let mut config = config(); config.barge_in = true; let mut seg = Segmenter::new(&config); @@ -372,25 +372,25 @@ mod tests { seg.set_gate(Gate::Open); assert!( !seg.is_speaking(), - "reabrir debe descartar lo acumulado; si no, el turno siguiente arranca con eco" + "reopening must drop what accumulated; otherwise the next turn starts with echo" ); } #[test] - fn el_suelo_de_ruido_no_sube_con_la_voz() { + fn noise_floor_does_not_rise_with_speech() { let mut seg = Segmenter::new(&config()); feed(&mut seg, 1.0, 0.0002); - let quieto = seg.noise_floor(); + let quiet = seg.noise_floor(); feed(&mut seg, 2.0, 0.5); assert!( - seg.noise_floor() <= quieto * 1.5, - "hablar no debe elevar el suelo de ruido ({quieto} -> {})", + seg.noise_floor() <= quiet * 1.5, + "speech must not raise the noise floor ({quiet} -> {})", seg.noise_floor() ); } #[test] - fn el_cierre_entrega_la_intervencion_a_medias() { + fn shutdown_delivers_the_unfinished_utterance() { let mut seg = Segmenter::new(&config()); feed(&mut seg, 1.0, 0.0002); feed(&mut seg, 0.5, 0.3); |