aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/capture.rs
blob: 5e3a6975dd46274afe1799079bbdb14c35fe6bd4 (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
//! Microphone capture.

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};

/// Format the device was actually opened with.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InputFormat {
    pub sample_rate: usize,
    pub channels: usize,
}

/// A block as it comes out of the callback, with the timestamp that later
/// lets us measure how far the pipeline lags behind the voice.
#[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 {
    /// Opens the input and starts pushing blocks through `tx`.
    ///
    /// It prefers 16 kHz mono because that is exactly what the model wants: if
    /// the device accepts it, there is no resampling anywhere on the path.
    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, "input stream");

        // Inside the callback: convert to f32, send and leave. `send` on an
        // unbounded channel does not block, which is the only property that
        // matters here.
        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!("could not open the input: {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!(
                    "unsupported sample format: {other:?}"
                )))
            }
        };
        stream
            .play()
            .map_err(|e| Error::Audio(format!("could not start the input: {e}")))?;

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

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

    /// Closes the device. Dropping the sender makes the chain of threads
    /// take itself apart from top to bottom.
    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 input device".into()));
    }
    let wanted_lower = wanted.to_lowercase();
    let devices = host
        .input_devices()
        .map_err(|e| Error::Audio(format!("could not list the inputs: {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!(
        "no input matches «{wanted}». Available: {}",
        seen.join(", ")
    )))
}

fn preferred_config(device: &cpal::Device) -> Result<SupportedStreamConfig> {
    let native = device
        .supported_input_configs()
        .map_err(|e| Error::Audio(format!("could not query the input: {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!("no input configuration: {e}"))),
    }
}