brainwires_hardware/audio/
assistant.rs1use 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
27const STATE_IDLE: u8 = 0;
30const STATE_LISTENING: u8 = 1;
31const STATE_PROCESSING: u8 = 2;
32const STATE_SPEAKING: u8 = 3;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AssistantState {
37 Idle,
39 Listening,
41 Processing,
43 Speaking,
45}
46
47#[derive(Debug, Clone)]
51pub struct VoiceAssistantConfig {
52 pub capture_config: AudioConfig,
54 pub silence_threshold_db: f32,
57 pub silence_duration_ms: u32,
59 pub max_record_secs: f64,
61 pub stt_options: SttOptions,
63 pub tts_options: Option<TtsOptions>,
65 pub microphone: Option<AudioDevice>,
67 pub speaker: Option<AudioDevice>,
69 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#[async_trait]
94pub trait VoiceAssistantHandler: Send + Sync {
95 #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
98 async fn on_wake_word(&self, _detection: &WakeWordDetection) {}
99
100 async fn on_speech(&self, transcript: &crate::audio::types::Transcript) -> Option<String>;
105
106 async fn on_error(&self, _error: &AudioError) {}
108}
109
110pub 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 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 pub fn with_playback(mut self, p: Arc<dyn AudioPlayback>) -> Self {
141 self.playback = Some(p);
142 self
143 }
144
145 pub fn with_tts(mut self, tts: Arc<dyn TextToSpeech>) -> Self {
147 self.tts = Some(tts);
148 self
149 }
150
151 #[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 pub fn with_vad(mut self, vad: Box<dyn VoiceActivityDetector>) -> Self {
160 self.vad = Some(vad);
161 self
162 }
163
164 pub fn with_config(mut self, config: VoiceAssistantConfig) -> Self {
166 self.config = config;
167 self
168 }
169
170 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
192pub 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 pub fn builder(
215 capture: Arc<dyn AudioCapture>,
216 stt: Arc<dyn SpeechToText>,
217 ) -> VoiceAssistantBuilder {
218 VoiceAssistantBuilder::new(capture, stt)
219 }
220
221 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 pub fn stop(&mut self) {
234 self.stop_flag.store(true, Ordering::Relaxed);
235 }
236
237 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 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 #[cfg(any(feature = "wake-word", feature = "wake-word-dtw"))]
276 {
277 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 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 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 let reply = handler.on_speech(&transcript).await;
354
355 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 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 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 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 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); if silence_ms >= silence_limit_ms {
439 debug!("End of utterance (silence={silence_ms}ms)");
440 break;
441 }
442 }
443 }
444
445 if ring.duration_secs() >= self.config.max_record_secs {
447 debug!("Max record duration reached");
448 break;
449 }
450
451 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 #[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}