diff options
Diffstat (limited to 'crates/asist-audio/src/lib.rs')
| -rw-r--r-- | crates/asist-audio/src/lib.rs | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/crates/asist-audio/src/lib.rs b/crates/asist-audio/src/lib.rs new file mode 100644 index 0000000..f6979ca --- /dev/null +++ b/crates/asist-audio/src/lib.rs @@ -0,0 +1,148 @@ +//! Entrada y salida de audio, y el detector de voz que las separa en turnos. +//! +//! 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. + +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. +pub use cpal; + +pub use capture::{Capture, CaptureBlock, InputFormat}; +pub use playback::{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. +pub const ASR_SAMPLE_RATE: u32 = 16_000; + +/// Frecuencia a la que sintetiza qwentts. +pub const TTS_SAMPLE_RATE: u32 = 24_000; + +/// Nombre legible de un dispositivo. +/// +/// `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. +pub fn describe(device: &impl cpal::traits::DeviceTrait) -> String { + device + .description() + .map(|d| d.name().to_string()) + .unwrap_or_else(|_| "desconocido".into()) +} + +/// Nivel RMS de un bloque, la medida con la que el VAD decide. +pub fn rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let sum: f32 = samples.iter().map(|v| v * v).sum(); + (sum / samples.len() as f32).sqrt() +} + +/// Mezcla a mono y remuestrea linealmente a `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. +pub fn to_mono_at(samples: &[f32], format: InputFormat, target: u32) -> Vec<f32> { + let mono: Vec<f32> = if format.channels > 1 { + samples + .chunks(format.channels) + .map(|frame| frame.iter().sum::<f32>() / format.channels as f32) + .collect() + } else { + samples.to_vec() + }; + + if format.sample_rate == target as usize { + return mono; + } + let ratio = target as f64 / format.sample_rate as f64; + let out_len = (mono.len() as f64 * ratio) as usize; + (0..out_len) + .map(|i| { + let pos = i as f64 / ratio; + let idx = pos as usize; + let frac = (pos - idx as f64) as f32; + let a = mono.get(idx).copied().unwrap_or(0.0); + let b = mono.get(idx + 1).copied().unwrap_or(a); + a + (b - a) * frac + }) + .collect() +} + +/// Convierte s16le a f32 en [-1, 1]. Es el formato en el que el TTS entrega. +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]]); + out.push(sample as f32 / 32768.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn el_rms_de_una_senal_constante_es_su_amplitud() { + assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-6); + assert_eq!(rms(&[]), 0.0); + } + + #[test] + fn el_estereo_se_mezcla_a_mono_promediando() { + let format = InputFormat { + sample_rate: 16_000, + channels: 2, + }; + let out = to_mono_at(&[1.0, 0.0, 0.5, 0.5], format, 16_000); + assert_eq!(out, vec![0.5, 0.5]); + } + + #[test] + fn el_remuestreo_ajusta_la_duracion() { + 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"); + } + + #[test] + fn a_la_misma_frecuencia_el_remuestreo_no_toca_nada() { + let format = InputFormat { + sample_rate: 16_000, + channels: 1, + }; + let input = vec![0.1, -0.2, 0.3]; + assert_eq!(to_mono_at(&input, format, 16_000), input); + } + + #[test] + fn s16le_recorre_el_rango_completo() { + let mut out = Vec::new(); + s16le_to_f32(&[0x00, 0x00, 0xff, 0x7f, 0x00, 0x80], &mut out); + assert_eq!(out[0], 0.0); + assert!((out[1] - 1.0).abs() < 1e-4); + assert!((out[2] + 1.0).abs() < 1e-6); + } + + #[test] + fn un_byte_suelto_no_produce_una_muestra_a_medias() { + 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" + ); + } +} |