Skip to main content

brainwires_hardware/audio/
assistant.rs

1use std::sync::{
2    Arc,
3    atomic::{AtomicBool, AtomicU8, Ordering},
4};
5use std::time::Instant;
6
7use async_trait::async_trait;
8use futures::StreamExt;
9use tokio::sync::oneshot;
10use tracing::{debug, info, warn};
11
12use crate::audio::vad::{VoiceActivityDetector, energy::EnergyVad};
13use crate::audio::{
14    buffer::AudioRingBuffer,
15    capture::AudioCapture,
16    device::AudioDevice,
17    error::{AudioError, AudioResult},
18    playback::AudioPlayback,
19    stt::SpeechToText,
20    tts::TextToSpeech,
21    types::{AudioBuffer, AudioConfig, SampleFormat, SttOptions, TtsOptions},
22};
23
24#[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
25use crate::audio::wake_word::{WakeWordDetection, WakeWordDetector};
26
27// ── State enum ────────────────────────────────────────────────────────────────
28
29const STATE_IDLE: u8 = 0;
30const STATE_LISTENING: u8 = 1;
31const STATE_PROCESSING: u8 = 2;
32const STATE_SPEAKING: u8 = 3;
33
34/// Current operational state of the voice assistant.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AssistantState {
37    /// Waiting for a wake word (or for `listen_once` to be called).
38    Idle,
39    /// Capturing user speech.
40    Listening,
41    /// Running STT + calling the handler.
42    Processing,
43    /// Playing back a TTS response.
44    Speaking,
45}
46
47// ── Config ────────────────────────────────────────────────────────────────────
48
49/// Configuration for the voice assistant pipeline.
50#[derive(Debug, Clone)]
51pub struct VoiceAssistantConfig {
52    /// Capture format. Default: `AudioConfig::speech()` (16 kHz mono i16).
53    pub capture_config: AudioConfig,
54    /// Energy threshold in dBFS below which audio is considered silent.
55    /// Default: -40 dB.
56    pub silence_threshold_db: f32,
57    /// How many milliseconds of silence ends an utterance. Default: 800 ms.
58    pub silence_duration_ms: u32,
59    /// Maximum recording duration safety ceiling (seconds). Default: 30 s.
60    pub max_record_secs: f64,
61    /// STT options forwarded to the speech-to-text backend.
62    pub stt_options: SttOptions,
63    /// TTS options. `None` means no spoken response.
64    pub tts_options: Option<TtsOptions>,
65    /// Microphone device. `None` uses the system default.
66    pub microphone: Option<AudioDevice>,
67    /// Speaker device. `None` uses the system default.
68    pub speaker: Option<AudioDevice>,
69    /// How long to listen before entering the Idle/wake-word state again when
70    /// no speech is detected (seconds). Default: 10 s.
71    pub listen_timeout_secs: f64,
72}
73
74impl Default for VoiceAssistantConfig {
75    fn default() -> Self {
76        Self {
77            capture_config: AudioConfig::speech(),
78            silence_threshold_db: -40.0,
79            silence_duration_ms: 800,
80            max_record_secs: 30.0,
81            stt_options: SttOptions::default(),
82            tts_options: None,
83            microphone: None,
84            speaker: None,
85            listen_timeout_secs: 10.0,
86        }
87    }
88}
89
90// ── Handler trait ─────────────────────────────────────────────────────────────
91
92/// Callbacks invoked by [`VoiceAssistant::run`] during the pipeline.
93#[async_trait]
94pub trait VoiceAssistantHandler: Send + Sync {
95    /// Called when a wake word fires (before listening begins).
96    /// Override to provide feedback (e.g. a chime sound or LED flash).
97    #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
98    async fn on_wake_word(&self, _detection: &WakeWordDetection) {}
99
100    /// Called with the completed transcript.
101    ///
102    /// Return `Some(text)` to have the assistant speak a response via TTS.
103    /// Return `None` for a silent acknowledgement (e.g. action-only commands).
104    async fn on_speech(&self, transcript: &crate::audio::types::Transcript) -> Option<String>;
105
106    /// Called on non-fatal errors (capture glitches, STT failures, etc.).
107    async fn on_error(&self, _error: &AudioError) {}
108}
109
110// ── Builder ───────────────────────────────────────────────────────────────────
111
112/// Builder for [`VoiceAssistant`].
113pub struct VoiceAssistantBuilder {
114    capture: Arc<dyn AudioCapture>,
115    stt: Arc<dyn SpeechToText>,
116    playback: Option<Arc<dyn AudioPlayback>>,
117    tts: Option<Arc<dyn TextToSpeech>>,
118    #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
119    wake_word: Option<Box<dyn WakeWordDetector>>,
120    vad: Option<Box<dyn VoiceActivityDetector>>,
121    config: VoiceAssistantConfig,
122}
123
124impl VoiceAssistantBuilder {
125    /// Start a new builder with the minimum required components.
126    pub fn new(capture: Arc<dyn AudioCapture>, stt: Arc<dyn SpeechToText>) -> Self {
127        Self {
128            capture,
129            stt,
130            playback: None,
131            tts: None,
132            #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
133            wake_word: None,
134            vad: None,
135            config: VoiceAssistantConfig::default(),
136        }
137    }
138
139    /// Set the audio playback backend for TTS output.
140    pub fn with_playback(mut self, p: Arc<dyn AudioPlayback>) -> Self {
141        self.playback = Some(p);
142        self
143    }
144
145    /// Set the text-to-speech backend for spoken responses.
146    pub fn with_tts(mut self, tts: Arc<dyn TextToSpeech>) -> Self {
147        self.tts = Some(tts);
148        self
149    }
150
151    /// Set the wake word detector used to activate the listening phase.
152    #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
153    pub fn with_wake_word(mut self, detector: Box<dyn WakeWordDetector>) -> Self {
154        self.wake_word = Some(detector);
155        self
156    }
157
158    /// Override the default `EnergyVad` with a custom VAD implementation.
159    pub fn with_vad(mut self, vad: Box<dyn VoiceActivityDetector>) -> Self {
160        self.vad = Some(vad);
161        self
162    }
163
164    /// Replace the default [`VoiceAssistantConfig`].
165    pub fn with_config(mut self, config: VoiceAssistantConfig) -> Self {
166        self.config = config;
167        self
168    }
169
170    /// Consume the builder and produce a [`VoiceAssistant`].
171    pub fn build(self) -> VoiceAssistant {
172        let vad: Box<dyn VoiceActivityDetector> = self
173            .vad
174            .unwrap_or_else(|| Box::new(EnergyVad::new(self.config.silence_threshold_db)));
175
176        VoiceAssistant {
177            config: self.config,
178            capture: self.capture,
179            playback: self.playback,
180            stt: self.stt,
181            tts: self.tts,
182            #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
183            wake_word: self.wake_word,
184            vad,
185            state: Arc::new(AtomicU8::new(STATE_IDLE)),
186            stop_flag: Arc::new(AtomicBool::new(false)),
187            _stop_tx: None,
188        }
189    }
190}
191
192// ── VoiceAssistant ────────────────────────────────────────────────────────────
193
194/// A voice assistant that orchestrates the full pipeline:
195/// listen → (wake word) → VAD-gated capture → STT → handler → TTS → playback.
196///
197/// Create via [`VoiceAssistant::builder`] (or [`VoiceAssistantBuilder::new`]).
198pub struct VoiceAssistant {
199    config: VoiceAssistantConfig,
200    capture: Arc<dyn AudioCapture>,
201    playback: Option<Arc<dyn AudioPlayback>>,
202    stt: Arc<dyn SpeechToText>,
203    tts: Option<Arc<dyn TextToSpeech>>,
204    #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
205    wake_word: Option<Box<dyn WakeWordDetector>>,
206    vad: Box<dyn VoiceActivityDetector>,
207    state: Arc<AtomicU8>,
208    stop_flag: Arc<AtomicBool>,
209    _stop_tx: Option<oneshot::Sender<()>>,
210}
211
212impl VoiceAssistant {
213    /// Create a new builder with the required capture and STT backends.
214    pub fn builder(
215        capture: Arc<dyn AudioCapture>,
216        stt: Arc<dyn SpeechToText>,
217    ) -> VoiceAssistantBuilder {
218        VoiceAssistantBuilder::new(capture, stt)
219    }
220
221    /// Current operational state.
222    pub fn state(&self) -> AssistantState {
223        match self.state.load(Ordering::Relaxed) {
224            STATE_IDLE => AssistantState::Idle,
225            STATE_LISTENING => AssistantState::Listening,
226            STATE_PROCESSING => AssistantState::Processing,
227            STATE_SPEAKING => AssistantState::Speaking,
228            _ => AssistantState::Idle,
229        }
230    }
231
232    /// Signal the running loop to stop after the current utterance completes.
233    pub fn stop(&mut self) {
234        self.stop_flag.store(true, Ordering::Relaxed);
235    }
236
237    // ── listen_once ───────────────────────────────────────────────────────────
238
239    /// Capture a single utterance (VAD-gated or timed) and return the
240    /// transcript. Does **not** invoke wake word detection or handler callbacks.
241    pub async fn listen_once(&mut self) -> AudioResult<crate::audio::types::Transcript> {
242        let captured = self.capture_utterance().await?;
243        self.state.store(STATE_PROCESSING, Ordering::Relaxed);
244        let transcript = self
245            .stt
246            .transcribe(&captured, &self.config.stt_options)
247            .await?;
248        self.state.store(STATE_IDLE, Ordering::Relaxed);
249        Ok(transcript)
250    }
251
252    // ── run ───────────────────────────────────────────────────────────────────
253
254    /// Run the full assistant event loop indefinitely.
255    ///
256    /// The loop:
257    /// 1. Listens for a wake word (if configured), then enters Listening state.
258    /// 2. In Listening state, accumulates speech via VAD into an `AudioRingBuffer`.
259    /// 3. Calls `handler.on_speech()` with the completed transcript.
260    /// 4. If the handler returns text and TTS is configured, speaks the reply.
261    /// 5. Loops back to step 1.
262    ///
263    /// Call [`stop`][Self::stop] to terminate cleanly after the current cycle.
264    pub async fn run<H: VoiceAssistantHandler>(&mut self, handler: &H) -> AudioResult<()> {
265        self.stop_flag.store(false, Ordering::Relaxed);
266        info!("VoiceAssistant started");
267
268        loop {
269            if self.stop_flag.load(Ordering::Relaxed) {
270                info!("VoiceAssistant stopping");
271                break;
272            }
273
274            // ── Wake word phase ───────────────────────────────────────────────
275            #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
276            {
277                // Take detector out to avoid simultaneous &self borrow conflict.
278                let mut detector = self.wake_word.take();
279                let wake_result = if let Some(ref mut det) = detector {
280                    Some(
281                        Self::wait_for_wake_word_inner(
282                            &self.capture,
283                            &self.config,
284                            &self.stop_flag,
285                            det,
286                        )
287                        .await,
288                    )
289                } else {
290                    None
291                };
292                self.wake_word = detector;
293
294                match wake_result {
295                    Some(Ok(det)) => {
296                        info!(keyword = %det.keyword, score = det.score, "Wake word detected");
297                        handler.on_wake_word(&det).await;
298                    }
299                    Some(Err(e)) => {
300                        warn!("Wake word error: {e}");
301                        handler.on_error(&e).await;
302                        continue;
303                    }
304                    None => {}
305                }
306            }
307
308            if self.stop_flag.load(Ordering::Relaxed) {
309                break;
310            }
311
312            // ── Capture utterance ─────────────────────────────────────────────
313            self.state.store(STATE_LISTENING, Ordering::Relaxed);
314            let captured = match self.capture_utterance().await {
315                Ok(buf) => buf,
316                Err(e) => {
317                    handler.on_error(&e).await;
318                    self.state.store(STATE_IDLE, Ordering::Relaxed);
319                    continue;
320                }
321            };
322
323            if captured.is_empty() {
324                debug!("No speech captured — returning to idle");
325                self.state.store(STATE_IDLE, Ordering::Relaxed);
326                continue;
327            }
328
329            // ── STT ───────────────────────────────────────────────────────────
330            self.state.store(STATE_PROCESSING, Ordering::Relaxed);
331            let transcript = match self
332                .stt
333                .transcribe(&captured, &self.config.stt_options)
334                .await
335            {
336                Ok(t) => t,
337                Err(e) => {
338                    handler.on_error(&e).await;
339                    self.state.store(STATE_IDLE, Ordering::Relaxed);
340                    continue;
341                }
342            };
343
344            if transcript.text.trim().is_empty() {
345                debug!("STT returned empty transcript — returning to idle");
346                self.state.store(STATE_IDLE, Ordering::Relaxed);
347                continue;
348            }
349
350            info!(text = %transcript.text, "Transcript received");
351
352            // ── Handler callback ──────────────────────────────────────────────
353            let reply = handler.on_speech(&transcript).await;
354
355            // ── TTS + Playback ────────────────────────────────────────────────
356            if let (Some(text), Some(tts), Some(playback)) =
357                (reply, self.tts.as_ref(), self.playback.as_ref())
358            {
359                self.state.store(STATE_SPEAKING, Ordering::Relaxed);
360                let opts = self.config.tts_options.clone().unwrap_or_default();
361                match tts.synthesize(&text, &opts).await {
362                    Ok(audio) => {
363                        if let Err(e) = playback.play(self.config.speaker.as_ref(), &audio).await {
364                            handler.on_error(&e).await;
365                        }
366                    }
367                    Err(e) => handler.on_error(&e).await,
368                }
369            }
370
371            self.state.store(STATE_IDLE, Ordering::Relaxed);
372        }
373
374        Ok(())
375    }
376
377    // ── Internal helpers ──────────────────────────────────────────────────────
378
379    /// Capture one utterance from the microphone, gated by VAD.
380    async fn capture_utterance(&mut self) -> AudioResult<AudioBuffer> {
381        let config = &self.config.capture_config;
382        let mut stream = self
383            .capture
384            .start_capture(self.config.microphone.as_ref(), config)?;
385
386        let sr = config.sample_rate;
387        let bytes_per_sample = match config.sample_format {
388            SampleFormat::I16 => 2usize,
389            SampleFormat::F32 => 4,
390        };
391        // 20 ms per VAD chunk
392        let chunk_samples = (sr as usize * 20 / 1000) * config.channels as usize;
393        let chunk_bytes = chunk_samples * bytes_per_sample;
394
395        let mut ring = AudioRingBuffer::new(config.clone(), self.config.max_record_secs);
396        let mut silence_ms: u32 = 0;
397        let mut total_ms: u64;
398        let mut started_speaking = false;
399        let silence_limit_ms = self.config.silence_duration_ms;
400        let listen_timeout_ms = (self.config.listen_timeout_secs * 1000.0) as u64;
401
402        let mut pending: Vec<u8> = Vec::new();
403
404        let start = Instant::now();
405
406        while let Some(result) = stream.next().await {
407            let buf = match result {
408                Ok(b) => b,
409                Err(e) => return Err(e),
410            };
411
412            total_ms = start.elapsed().as_millis() as u64;
413
414            // Timeout if we haven't heard speech yet
415            if !started_speaking && total_ms > listen_timeout_ms {
416                debug!("Listen timeout — no speech detected");
417                break;
418            }
419
420            pending.extend_from_slice(&buf.data);
421
422            // Process complete 20 ms chunks
423            while pending.len() >= chunk_bytes {
424                let chunk_data: Vec<u8> = pending.drain(..chunk_bytes).collect();
425                let chunk_buf = AudioBuffer {
426                    data: chunk_data,
427                    config: config.clone(),
428                };
429                let is_speech = self.vad.is_speech(&chunk_buf);
430
431                if is_speech {
432                    started_speaking = true;
433                    silence_ms = 0;
434                    ring.push(&chunk_buf.data);
435                } else if started_speaking {
436                    silence_ms += 20;
437                    ring.push(&chunk_buf.data); // include trailing silence
438                    if silence_ms >= silence_limit_ms {
439                        debug!("End of utterance (silence={silence_ms}ms)");
440                        break;
441                    }
442                }
443            }
444
445            // Check ceiling
446            if ring.duration_secs() >= self.config.max_record_secs {
447                debug!("Max record duration reached");
448                break;
449            }
450
451            // Break on silence limit (inner loop may have set this)
452            if started_speaking && silence_ms >= silence_limit_ms {
453                break;
454            }
455        }
456
457        if ring.is_empty() {
458            return Ok(AudioBuffer::new(config.clone()));
459        }
460
461        Ok(AudioBuffer::from_pcm(ring.read_all(), config.clone()))
462    }
463
464    /// Block until the wake word fires, returning the detection.
465    /// Static to avoid borrow conflicts when `wake_word` is taken out of self.
466    #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
467    async fn wait_for_wake_word_inner(
468        capture: &Arc<dyn AudioCapture>,
469        cfg: &VoiceAssistantConfig,
470        stop_flag: &Arc<AtomicBool>,
471        detector: &mut Box<dyn WakeWordDetector>,
472    ) -> AudioResult<WakeWordDetection> {
473        use crate::audio::vad::pcm_to_i16_mono;
474
475        let config = &cfg.capture_config;
476        let mut stream = capture.start_capture(cfg.microphone.as_ref(), config)?;
477
478        let frame_size = detector.frame_size();
479
480        let mut sample_buf: Vec<i16> = Vec::new();
481
482        while let Some(result) = stream.next().await {
483            let buf = match result {
484                Ok(b) => b,
485                Err(e) => return Err(e),
486            };
487
488            let mono = pcm_to_i16_mono(&buf);
489            sample_buf.extend_from_slice(&mono);
490
491            while sample_buf.len() >= frame_size {
492                let frame: Vec<i16> = sample_buf.drain(..frame_size).collect();
493                if let Some(det) = detector.process_frame(&frame) {
494                    return Ok(det);
495                }
496            }
497
498            if stop_flag.load(Ordering::Relaxed) {
499                return Err(AudioError::StreamClosed("assistant stopped".into()));
500            }
501        }
502
503        Err(AudioError::StreamClosed("mic stream ended".into()))
504    }
505}