Skip to main content

aria_kit/audio/capture/microphone/
mod.rs

1//! The default microphone: opened once, then recording one utterance at a time.
2
3mod resampling;
4mod stream;
5mod transfer;
6mod wake_word;
7mod worker;
8
9use std::path::PathBuf;
10use std::sync::mpsc::Sender;
11use std::time::Duration;
12
13use crate::audio::recording;
14use crate::audio::spool;
15use crate::audio::{CaptureError, RecordingReader, StopSignal, Stopper};
16use crate::primitives::private_fs;
17use crate::voice_activity::{SileroVad, VoiceActivityDetector};
18use crate::wake_word::WakeWordDetector;
19
20use super::{AudioCapture, MicrophoneError, RecordingMode};
21
22pub use stream::check_default_input_device;
23pub use wake_word::{
24    WakeWordControl, WakeWordEvent, WakeWordFailure, WakeWordMonitor, WakeWordSuspension,
25};
26
27const DEFAULT_MAXIMUM_DURATION: Duration = Duration::from_secs(60);
28
29type Result<T> = std::result::Result<T, CaptureError>;
30
31/// The default microphone, ready to record. `open()` chooses a private
32/// per-user spool directory and a 60-second maximum duration; the builder
33/// overrides either.
34pub struct Microphone {
35    commands: Sender<worker::Command>,
36}
37
38impl Microphone {
39    pub fn open() -> Result<Self> {
40        Self::builder().open()
41    }
42
43    pub fn builder() -> MicrophoneBuilder {
44        MicrophoneBuilder {
45            spool_dir: None,
46            maximum_duration: DEFAULT_MAXIMUM_DURATION,
47        }
48    }
49
50    /// Records one utterance. The recording fills in the background; hand the
51    /// stopper to whatever should be able to end it early.
52    // `&mut self`: one capture worker serves one recording at a time, so a
53    // second concurrent record would silently queue behind the first.
54    pub fn record(&mut self, mode: RecordingMode) -> Result<(RecordingReader, Stopper)> {
55        let stopper = Stopper::new();
56        let recording = self.start(mode, StopSignal::from(stopper.clone()))?;
57        Ok((recording, stopper))
58    }
59}
60
61pub struct MicrophoneBuilder {
62    spool_dir: Option<PathBuf>,
63    maximum_duration: Duration,
64}
65
66impl MicrophoneBuilder {
67    /// Where the bounded temporary WAV lives while recording. It holds
68    /// everything the microphone hears, so use a directory only your user
69    /// can read. Without one, loading creates a private per-user directory.
70    pub fn spool_dir(mut self, directory: impl Into<PathBuf>) -> Self {
71        self.spool_dir = Some(directory.into());
72        self
73    }
74
75    /// The most a single recording may hold, up to about 37 hours. The spool
76    /// grows with it, about 115 MB per hour, and each reader holds what it
77    /// has not released, about 230 MB per hour.
78    pub fn maximum_duration(mut self, maximum_duration: Duration) -> Self {
79        self.maximum_duration = maximum_duration;
80        self
81    }
82
83    pub fn open(self) -> Result<Microphone> {
84        self.load()?.open()
85    }
86
87    pub fn load(self) -> Result<PreparedMicrophone> {
88        self.prepare(|| {
89            SileroVad::load().map_err(|error| MicrophoneError::VoiceActivity(error).into())
90        })
91    }
92
93    /// Uses `vad` instead of the bundled Silero model, for both endpointing
94    /// and the wake-word gate. A panic while it scores fails the recording
95    /// in progress, or drops the held stream when idle; a panic in its
96    /// `reset` ends the microphone.
97    pub fn load_with<V>(self, vad: V) -> Result<PreparedMicrophone<V>>
98    where
99        V: VoiceActivityDetector + Send + 'static,
100    {
101        self.prepare(|| Ok(vad))
102    }
103
104    /// Cheap checks first, so a model loads only once they pass.
105    fn prepare<V>(self, vad: impl FnOnce() -> Result<V>) -> Result<PreparedMicrophone<V>> {
106        spool::maximum_samples(self.maximum_duration)?;
107        let spool_dir = match self.spool_dir {
108            // A caller-chosen directory is theirs: no ownership or mode
109            // enforcement on a layout this crate does not own.
110            Some(directory) => directory,
111            None => private_spool_dir()?,
112        };
113        Ok(PreparedMicrophone {
114            spool_dir,
115            maximum_duration: self.maximum_duration,
116            vad: vad()?,
117        })
118    }
119}
120
121/// A microphone whose fallible setup is done. Opening it starts the worker
122/// thread, which opens the input device.
123pub struct PreparedMicrophone<V = SileroVad> {
124    spool_dir: PathBuf,
125    maximum_duration: Duration,
126    vad: V,
127}
128
129impl<V: VoiceActivityDetector + Send + 'static> PreparedMicrophone<V> {
130    pub fn open(self) -> Result<Microphone> {
131        let commands = worker::spawn(self.spool_dir, self.maximum_duration, self.vad)?;
132        Ok(Microphone { commands })
133    }
134
135    pub fn open_with_wake_word<W>(self, detector: W) -> Result<(Microphone, WakeWordMonitor)>
136    where
137        W: WakeWordDetector + Send + 'static,
138    {
139        let (commands, events) = worker::spawn_with_wake_word(
140            self.spool_dir,
141            self.maximum_duration,
142            self.vad,
143            detector,
144        )?;
145        let monitor = WakeWordMonitor::new(commands.clone(), events);
146        Ok((Microphone { commands }, monitor))
147    }
148}
149
150fn private_spool_dir() -> Result<PathBuf> {
151    let directory =
152        std::env::temp_dir().join(format!("aria-{}", rustix::process::getuid().as_raw()));
153    private_fs::prepare_directory(&directory).map_err(MicrophoneError::SpoolDirectory)?;
154    Ok(directory)
155}
156
157impl AudioCapture for Microphone {
158    fn start(&self, mode: RecordingMode, stop: StopSignal) -> Result<RecordingReader> {
159        let (mut recording, writer) = recording::ends();
160        self.commands
161            .send(worker::Command::Record(worker::RecordCommand {
162                mode,
163                writer,
164                stop,
165            }))
166            .map_err(|_command| MicrophoneError::WorkerGone)?;
167        // Waits for the worker to call `begin`, so the return marks when
168        // capture started. With the stream already open that is one round
169        // trip; otherwise the stream opens first.
170        recording.wait_for_capture()?;
171        Ok(recording)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::audio::SpoolError;
179
180    #[test]
181    fn a_maximum_duration_over_the_limit_is_refused_before_opening() {
182        let outcome = Microphone::builder()
183            .maximum_duration(crate::audio::MAX_RECORDING_DURATION + Duration::from_secs(1))
184            .open();
185
186        assert!(matches!(
187            outcome,
188            Err(CaptureError::Spool(SpoolError::InvalidDuration { .. }))
189        ));
190    }
191}