aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/capture.rs
blob: 05da9924d70d648f628b5892e72888a83e51861d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Captura desde el micrófono.

use std::time::Instant;

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Sample, SampleFormat, SupportedStreamConfig};
use crossbeam_channel::Sender;

use asist_core::config::AudioConfig;
use asist_core::error::{Error, Result};

use crate::{describe, ASR_SAMPLE_RATE};

/// Formato con el que se abrió realmente el dispositivo.
#[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.
#[derive(Debug)]
pub struct CaptureBlock {
    pub samples: Vec<f32>,
    pub at: Instant,
}

pub struct Capture {
    stream: cpal::Stream,
    pub format: InputFormat,
    pub device_name: String,
}

impl Capture {
    /// Abre la entrada y empieza a empujar bloques por `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.
    pub fn open(config: &AudioConfig, tx: Sender<CaptureBlock>) -> Result<Self> {
        let host = cpal::default_host();
        let device = select_device(&host, &config.input_device)?;
        let device_name = describe(&device);

        let supported = preferred_config(&device)?;
        let format = InputFormat {
            sample_rate: supported.sample_rate() as usize,
            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");

        // 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.
        macro_rules! build {
            ($sample:ty) => {
                device
                    .build_input_stream(
                        &stream_config,
                        move |data: &[$sample], _: &_| {
                            let samples = data.iter().map(|s| f32::from_sample(*s)).collect();
                            let _ = tx.send(CaptureBlock {
                                samples,
                                at: Instant::now(),
                            });
                        },
                        on_error,
                        None,
                    )
                    .map_err(|e| Error::Audio(format!("no se pudo abrir la entrada: {e}")))?
            };
        }

        let stream = match supported.sample_format() {
            SampleFormat::F32 => build!(f32),
            SampleFormat::I16 => build!(i16),
            SampleFormat::U16 => build!(u16),
            other => {
                return Err(Error::Audio(format!(
                    "formato de muestra no soportado: {other:?}"
                )))
            }
        };
        stream
            .play()
            .map_err(|e| Error::Audio(format!("no se pudo arrancar la entrada: {e}")))?;

        tracing::info!(
            target: "audio",
            dispositivo = %device_name,
            hz = format.sample_rate,
            canales = format.channels,
            remuestreo = format.sample_rate != ASR_SAMPLE_RATE as usize || format.channels != 1,
            "entrada abierta"
        );

        Ok(Self {
            stream,
            format,
            device_name,
        })
    }

    /// Cierra el dispositivo. Al soltar el emisor, la cadena de hilos se
    /// desmonta sola de arriba abajo.
    pub fn stop(self) {
        drop(self.stream);
    }
}

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()));
    }
    let wanted_lower = wanted.to_lowercase();
    let devices = host
        .input_devices()
        .map_err(|e| Error::Audio(format!("no se pudieron listar las entradas: {e}")))?;
    let mut seen = Vec::new();
    for device in devices {
        let name = describe(&device);
        if name.to_lowercase().contains(&wanted_lower) {
            return Ok(device);
        }
        seen.push(name);
    }
    Err(Error::Audio(format!(
        "ninguna entrada coincide con «{wanted}». Disponibles: {}",
        seen.join(", ")
    )))
}

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}")))?
        .filter(|range| range.channels() == 1)
        .filter(|range| {
            range.min_sample_rate() <= ASR_SAMPLE_RATE && ASR_SAMPLE_RATE <= range.max_sample_rate()
        })
        .find(|range| range.sample_format() == SampleFormat::F32)
        .map(|range| range.with_sample_rate(ASR_SAMPLE_RATE));

    match native {
        Some(config) => Ok(config),
        None => device
            .default_input_config()
            .map_err(|e| Error::Audio(format!("sin configuración de entrada: {e}"))),
    }
}