diff options
Diffstat (limited to 'crates/asist-audio/src/playback.rs')
| -rw-r--r-- | crates/asist-audio/src/playback.rs | 117 |
1 files changed, 62 insertions, 55 deletions
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]); |