use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::error::PluginError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeWordConfig {
#[serde(default = "default_model_path")]
pub model_path: PathBuf,
#[serde(default = "default_threshold")]
pub threshold: f32,
#[serde(default = "default_min_gap")]
pub min_gap_frames: usize,
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
#[serde(default = "default_true")]
pub log_detections: bool,
}
fn default_model_path() -> PathBuf {
PathBuf::from("models/voice/wake/hey-weft.rpw")
}
fn default_threshold() -> f32 {
0.5
}
fn default_min_gap() -> usize {
30
}
fn default_sample_rate() -> u32 {
16000
}
fn default_true() -> bool {
true
}
impl Default for WakeWordConfig {
fn default() -> Self {
Self {
model_path: default_model_path(),
threshold: default_threshold(),
min_gap_frames: default_min_gap(),
sample_rate: default_sample_rate(),
log_detections: default_true(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum WakeWordEvent {
Detected {
confidence: f32,
},
Started,
Stopped,
Error {
message: String,
},
}
pub struct WakeWordDetector {
config: WakeWordConfig,
running: bool,
}
impl WakeWordDetector {
pub fn new(config: WakeWordConfig) -> Result<Self, PluginError> {
info!(
model = %config.model_path.display(),
threshold = config.threshold,
"wake word detector created (stub)"
);
Ok(Self {
config,
running: false,
})
}
pub fn process_frame(&mut self, _samples: &[i16]) -> bool {
debug!("wake word: processing frame (stub, no detection)");
false
}
pub fn start(&mut self) -> WakeWordEvent {
self.running = true;
info!("wake word detector started (stub)");
WakeWordEvent::Started
}
pub fn stop(&mut self) -> WakeWordEvent {
self.running = false;
info!("wake word detector stopped (stub)");
WakeWordEvent::Stopped
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn config(&self) -> &WakeWordConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wake_word_config_defaults() {
let config = WakeWordConfig::default();
assert_eq!(
config.model_path,
PathBuf::from("models/voice/wake/hey-weft.rpw")
);
assert!((config.threshold - 0.5).abs() < f32::EPSILON);
assert_eq!(config.min_gap_frames, 30);
assert_eq!(config.sample_rate, 16000);
assert!(config.log_detections);
}
#[test]
fn wake_word_config_serde_roundtrip() {
let config = WakeWordConfig::default();
let json = serde_json::to_string(&config).unwrap();
let restored: WakeWordConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.model_path, restored.model_path);
assert!((config.threshold - restored.threshold).abs() < f32::EPSILON);
assert_eq!(config.min_gap_frames, restored.min_gap_frames);
assert_eq!(config.sample_rate, restored.sample_rate);
assert_eq!(config.log_detections, restored.log_detections);
}
#[test]
fn wake_word_config_custom_values() {
let json = r#"{
"model_path": "/custom/model.rpw",
"threshold": 0.8,
"min_gap_frames": 50,
"sample_rate": 48000,
"log_detections": false
}"#;
let config: WakeWordConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.model_path, PathBuf::from("/custom/model.rpw"));
assert!((config.threshold - 0.8).abs() < f32::EPSILON);
assert_eq!(config.min_gap_frames, 50);
assert_eq!(config.sample_rate, 48000);
assert!(!config.log_detections);
}
#[test]
fn wake_word_detector_create() {
let config = WakeWordConfig::default();
let detector = WakeWordDetector::new(config).unwrap();
assert!(!detector.is_running());
}
#[test]
fn wake_word_detector_start_stop_lifecycle() {
let config = WakeWordConfig::default();
let mut detector = WakeWordDetector::new(config).unwrap();
assert!(!detector.is_running());
let event = detector.start();
assert!(matches!(event, WakeWordEvent::Started));
assert!(detector.is_running());
let event = detector.stop();
assert!(matches!(event, WakeWordEvent::Stopped));
assert!(!detector.is_running());
}
#[test]
fn wake_word_detector_process_frame_returns_false() {
let config = WakeWordConfig::default();
let mut detector = WakeWordDetector::new(config).unwrap();
detector.start();
let samples = vec![0i16; 512];
assert!(!detector.process_frame(&samples));
}
#[test]
fn wake_word_detector_config_accessor() {
let config = WakeWordConfig {
threshold: 0.75,
..Default::default()
};
let detector = WakeWordDetector::new(config).unwrap();
assert!((detector.config().threshold - 0.75).abs() < f32::EPSILON);
}
#[test]
fn wake_word_event_serde_detected() {
let event = WakeWordEvent::Detected { confidence: 0.95 };
let json = serde_json::to_string(&event).unwrap();
let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
match restored {
WakeWordEvent::Detected { confidence } => {
assert!((confidence - 0.95).abs() < f32::EPSILON);
}
_ => panic!("expected Detected variant"),
}
}
#[test]
fn wake_word_event_serde_started() {
let event = WakeWordEvent::Started;
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"started\""));
let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(restored, WakeWordEvent::Started));
}
#[test]
fn wake_word_event_serde_stopped() {
let event = WakeWordEvent::Stopped;
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"stopped\""));
let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(restored, WakeWordEvent::Stopped));
}
#[test]
fn wake_word_event_serde_error() {
let event = WakeWordEvent::Error {
message: "model not found".into(),
};
let json = serde_json::to_string(&event).unwrap();
let restored: WakeWordEvent = serde_json::from_str(&json).unwrap();
match restored {
WakeWordEvent::Error { message } => {
assert_eq!(message, "model not found");
}
_ => panic!("expected Error variant"),
}
}
#[test]
fn wake_word_event_all_variants_serialize() {
let events = vec![
WakeWordEvent::Detected { confidence: 0.5 },
WakeWordEvent::Started,
WakeWordEvent::Stopped,
WakeWordEvent::Error {
message: "test".into(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let _: WakeWordEvent = serde_json::from_str(&json).unwrap();
}
}
}