clawft_plugin/voice/
wake.rs1use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use tracing::{debug, info};
13
14use crate::error::PluginError;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WakeWordConfig {
19 #[serde(default = "default_model_path")]
21 pub model_path: PathBuf,
22
23 #[serde(default = "default_threshold")]
25 pub threshold: f32,
26
27 #[serde(default = "default_min_gap")]
29 pub min_gap_frames: usize,
30
31 #[serde(default = "default_sample_rate")]
33 pub sample_rate: u32,
34
35 #[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#[non_exhaustive]
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(tag = "event", rename_all = "snake_case")]
72pub enum WakeWordEvent {
73 Detected {
75 confidence: f32,
77 },
78 Started,
80 Stopped,
82 Error {
84 message: String,
86 },
87}
88
89pub struct WakeWordDetector {
94 config: WakeWordConfig,
95 running: bool,
96}
97
98impl WakeWordDetector {
99 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 pub fn process_frame(&mut self, _samples: &[i16]) -> bool {
116 debug!("wake word: processing frame (stub, no detection)");
117 false
118 }
119
120 pub fn start(&mut self) -> WakeWordEvent {
122 self.running = true;
123 info!("wake word detector started (stub)");
124 WakeWordEvent::Started
125 }
126
127 pub fn stop(&mut self) -> WakeWordEvent {
129 self.running = false;
130 info!("wake word detector stopped (stub)");
131 WakeWordEvent::Stopped
132 }
133
134 pub fn is_running(&self) -> bool {
136 self.running
137 }
138
139 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 assert!(!detector.is_running());
205
206 let event = detector.start();
208 assert!(matches!(event, WakeWordEvent::Started));
209 assert!(detector.is_running());
210
211 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}