Skip to main content

clawft_plugin/voice/
wake.rs

1//! Wake word detection for "Hey Weft" trigger phrase.
2//!
3//! Provides the `WakeWordDetector` that processes audio frames and
4//! fires a detection event when the wake word is recognized.
5//!
6//! Currently a **stub implementation** -- real rustpotter integration
7//! is deferred until after VP validation.
8
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use tracing::{debug, info};
13
14use crate::error::PluginError;
15
16/// Configuration for the wake word detector.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WakeWordConfig {
19    /// Path to the wake word model file (.rpw).
20    #[serde(default = "default_model_path")]
21    pub model_path: PathBuf,
22
23    /// Detection threshold (0.0-1.0). Lower = more sensitive.
24    #[serde(default = "default_threshold")]
25    pub threshold: f32,
26
27    /// Minimum gap between detections in frames.
28    #[serde(default = "default_min_gap")]
29    pub min_gap_frames: usize,
30
31    /// Audio sample rate.
32    #[serde(default = "default_sample_rate")]
33    pub sample_rate: u32,
34
35    /// Whether to log detection events.
36    #[serde(default = "default_true")]
37    pub log_detections: bool,
38}
39
40fn default_model_path() -> PathBuf {
41    PathBuf::from("models/voice/wake/hey-weft.rpw")
42}
43fn default_threshold() -> f32 {
44    0.5
45}
46fn default_min_gap() -> usize {
47    30
48}
49fn default_sample_rate() -> u32 {
50    16000
51}
52fn default_true() -> bool {
53    true
54}
55
56impl Default for WakeWordConfig {
57    fn default() -> Self {
58        Self {
59            model_path: default_model_path(),
60            threshold: default_threshold(),
61            min_gap_frames: default_min_gap(),
62            sample_rate: default_sample_rate(),
63            log_detections: default_true(),
64        }
65    }
66}
67
68/// Events emitted by the wake word detector.
69#[non_exhaustive]
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(tag = "event", rename_all = "snake_case")]
72pub enum WakeWordEvent {
73    /// Wake word was detected with the given confidence.
74    Detected {
75        /// Detection confidence score (0.0-1.0).
76        confidence: f32,
77    },
78    /// Detector started listening.
79    Started,
80    /// Detector stopped listening.
81    Stopped,
82    /// An error occurred during detection.
83    Error {
84        /// Error description.
85        message: String,
86    },
87}
88
89/// Wake word detector (STUB implementation).
90///
91/// Real implementation will use rustpotter for "Hey Weft" detection.
92/// This stub provides the API surface for integration testing.
93pub struct WakeWordDetector {
94    config: WakeWordConfig,
95    running: bool,
96}
97
98impl WakeWordDetector {
99    /// Create a new wake word detector with the given configuration.
100    pub fn new(config: WakeWordConfig) -> Result<Self, PluginError> {
101        info!(
102            model = %config.model_path.display(),
103            threshold = config.threshold,
104            "wake word detector created (stub)"
105        );
106        Ok(Self {
107            config,
108            running: false,
109        })
110    }
111
112    /// Process a single audio frame. Returns `true` if wake word detected.
113    ///
114    /// STUB: Always returns `false`.
115    pub fn process_frame(&mut self, _samples: &[i16]) -> bool {
116        debug!("wake word: processing frame (stub, no detection)");
117        false
118    }
119
120    /// Start listening for the wake word.
121    pub fn start(&mut self) -> WakeWordEvent {
122        self.running = true;
123        info!("wake word detector started (stub)");
124        WakeWordEvent::Started
125    }
126
127    /// Stop listening for the wake word.
128    pub fn stop(&mut self) -> WakeWordEvent {
129        self.running = false;
130        info!("wake word detector stopped (stub)");
131        WakeWordEvent::Stopped
132    }
133
134    /// Check if the detector is currently running.
135    pub fn is_running(&self) -> bool {
136        self.running
137    }
138
139    /// Get the current configuration.
140    pub fn config(&self) -> &WakeWordConfig {
141        &self.config
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn wake_word_config_defaults() {
151        let config = WakeWordConfig::default();
152        assert_eq!(
153            config.model_path,
154            PathBuf::from("models/voice/wake/hey-weft.rpw")
155        );
156        assert!((config.threshold - 0.5).abs() < f32::EPSILON);
157        assert_eq!(config.min_gap_frames, 30);
158        assert_eq!(config.sample_rate, 16000);
159        assert!(config.log_detections);
160    }
161
162    #[test]
163    fn wake_word_config_serde_roundtrip() {
164        let config = WakeWordConfig::default();
165        let json = serde_json::to_string(&config).unwrap();
166        let restored: WakeWordConfig = serde_json::from_str(&json).unwrap();
167        assert_eq!(config.model_path, restored.model_path);
168        assert!((config.threshold - restored.threshold).abs() < f32::EPSILON);
169        assert_eq!(config.min_gap_frames, restored.min_gap_frames);
170        assert_eq!(config.sample_rate, restored.sample_rate);
171        assert_eq!(config.log_detections, restored.log_detections);
172    }
173
174    #[test]
175    fn wake_word_config_custom_values() {
176        let json = r#"{
177            "model_path": "/custom/model.rpw",
178            "threshold": 0.8,
179            "min_gap_frames": 50,
180            "sample_rate": 48000,
181            "log_detections": false
182        }"#;
183        let config: WakeWordConfig = serde_json::from_str(json).unwrap();
184        assert_eq!(config.model_path, PathBuf::from("/custom/model.rpw"));
185        assert!((config.threshold - 0.8).abs() < f32::EPSILON);
186        assert_eq!(config.min_gap_frames, 50);
187        assert_eq!(config.sample_rate, 48000);
188        assert!(!config.log_detections);
189    }
190
191    #[test]
192    fn wake_word_detector_create() {
193        let config = WakeWordConfig::default();
194        let detector = WakeWordDetector::new(config).unwrap();
195        assert!(!detector.is_running());
196    }
197
198    #[test]
199    fn wake_word_detector_start_stop_lifecycle() {
200        let config = WakeWordConfig::default();
201        let mut detector = WakeWordDetector::new(config).unwrap();
202
203        // Initially not running.
204        assert!(!detector.is_running());
205
206        // Start.
207        let event = detector.start();
208        assert!(matches!(event, WakeWordEvent::Started));
209        assert!(detector.is_running());
210
211        // Stop.
212        let event = detector.stop();
213        assert!(matches!(event, WakeWordEvent::Stopped));
214        assert!(!detector.is_running());
215    }
216
217    #[test]
218    fn wake_word_detector_process_frame_returns_false() {
219        let config = WakeWordConfig::default();
220        let mut detector = WakeWordDetector::new(config).unwrap();
221        detector.start();
222
223        let samples = vec![0i16; 512];
224        assert!(!detector.process_frame(&samples));
225    }
226
227    #[test]
228    fn wake_word_detector_config_accessor() {
229        let config = WakeWordConfig {
230            threshold: 0.75,
231            ..Default::default()
232        };
233        let detector = WakeWordDetector::new(config).unwrap();
234        assert!((detector.config().threshold - 0.75).abs() < f32::EPSILON);
235    }
236
237    #[test]
238    fn wake_word_event_serde_detected() {
239        let event = WakeWordEvent::Detected { confidence: 0.95 };
240        let json = serde_json::to_string(&event).unwrap();
241        let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
242        match restored {
243            WakeWordEvent::Detected { confidence } => {
244                assert!((confidence - 0.95).abs() < f32::EPSILON);
245            }
246            _ => panic!("expected Detected variant"),
247        }
248    }
249
250    #[test]
251    fn wake_word_event_serde_started() {
252        let event = WakeWordEvent::Started;
253        let json = serde_json::to_string(&event).unwrap();
254        assert!(json.contains("\"started\""));
255        let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
256        assert!(matches!(restored, WakeWordEvent::Started));
257    }
258
259    #[test]
260    fn wake_word_event_serde_stopped() {
261        let event = WakeWordEvent::Stopped;
262        let json = serde_json::to_string(&event).unwrap();
263        assert!(json.contains("\"stopped\""));
264        let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
265        assert!(matches!(restored, WakeWordEvent::Stopped));
266    }
267
268    #[test]
269    fn wake_word_event_serde_error() {
270        let event = WakeWordEvent::Error {
271            message: "model not found".into(),
272        };
273        let json = serde_json::to_string(&event).unwrap();
274        let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
275        match restored {
276            WakeWordEvent::Error { message } => {
277                assert_eq!(message, "model not found");
278            }
279            _ => panic!("expected Error variant"),
280        }
281    }
282
283    #[test]
284    fn wake_word_event_all_variants_serialize() {
285        let events = vec![
286            WakeWordEvent::Detected { confidence: 0.5 },
287            WakeWordEvent::Started,
288            WakeWordEvent::Stopped,
289            WakeWordEvent::Error {
290                message: "test".into(),
291            },
292        ];
293        for event in &events {
294            let json = serde_json::to_string(event).unwrap();
295            let _: WakeWordEvent = serde_json::from_str(&json).unwrap();
296        }
297    }
298}