aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/playback.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-audio/src/playback.rs')
-rw-r--r--crates/asist-audio/src/playback.rs117
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]);