1use std::collections::VecDeque;
2use std::path::{Path, PathBuf};
3
4use tflite_c::{Interpreter, InterpreterOptions, Model, TfLiteType};
5
6use crate::config::{Config, ModelMetadata};
7use crate::features::{FEATURE_COUNT, FEATURE_SCALE, Frontend};
8use crate::{AUDIO_BLOCK_SAMPLES, Error, Result, Runtime};
9
10#[derive(Clone, Debug, PartialEq)]
12pub struct Detection {
13 pub wake_word: String,
14 pub probability: f32,
16}
17
18pub struct Detector {
20 config: Config,
21 runtime: Runtime,
22 frontend: Frontend,
23 engine: Engine,
24 feature_rows: Vec<[u16; FEATURE_COUNT]>,
25 probabilities: VecDeque<f32>,
26}
27
28struct Engine {
29 interpreter: Interpreter,
30 input_scale: f32,
31 input_zero_point: i32,
32 model_rows: usize,
33}
34
35impl Engine {
36 fn new(config: &Config, runtime: &Runtime) -> Result<Self> {
37 let library = runtime.load()?;
38 let model = Model::from_file(&config.model_path, library.clone())?;
39 let mut options = InterpreterOptions::new(library);
40 options.num_threads(1);
41 let interpreter = Interpreter::new(model, options)?;
42
43 if interpreter.input_count() != 1 {
44 return Err(Error::IncompatibleModel(format!(
45 "expected one input tensor, got {}",
46 interpreter.input_count()
47 )));
48 }
49 if interpreter.output_count() != 1 {
50 return Err(Error::IncompatibleModel(format!(
51 "expected one output tensor, got {}",
52 interpreter.output_count()
53 )));
54 }
55 let input = interpreter.input(0)?;
56 let dims = input.dims();
57 if dims.len() != 3 || dims[0] != 1 || dims[1] <= 0 || dims[2] != FEATURE_COUNT as i32 {
58 return Err(Error::IncompatibleModel(format!(
59 "expected int8 input [1, rows, {FEATURE_COUNT}], got {:?}",
60 dims
61 )));
62 }
63 if input.dtype() != TfLiteType::Int8 {
64 return Err(Error::IncompatibleModel(format!(
65 "expected int8 input, got {:?}",
66 input.dtype()
67 )));
68 }
69 let quantization = input.quantization();
70 if !quantization.scale.is_finite() || quantization.scale <= 0.0 {
71 return Err(Error::IncompatibleModel(
72 "input tensor has invalid quantization scale".into(),
73 ));
74 }
75 let model_rows = dims[1] as usize;
76 let output = interpreter.output(0)?;
77 let output_elements = output
78 .dims()
79 .iter()
80 .try_fold(1_i32, |n, dim| n.checked_mul(*dim));
81 if output_elements != Some(1) {
82 return Err(Error::IncompatibleModel(format!(
83 "expected a single probability output, got shape {:?}",
84 output.dims()
85 )));
86 }
87 if !matches!(
88 output.dtype(),
89 TfLiteType::Int8 | TfLiteType::UInt8 | TfLiteType::Float32
90 ) {
91 return Err(Error::IncompatibleModel(format!(
92 "expected a quantized or float probability output, got {:?}",
93 output.dtype()
94 )));
95 }
96 if output.dtype() != TfLiteType::Float32 {
97 let quantization = output.quantization();
98 if !quantization.scale.is_finite() || quantization.scale <= 0.0 {
99 return Err(Error::IncompatibleModel(
100 "output tensor has invalid quantization scale".into(),
101 ));
102 }
103 }
104
105 Ok(Self {
106 interpreter,
107 input_scale: quantization.scale,
108 input_zero_point: quantization.zero_point,
109 model_rows,
110 })
111 }
112}
113
114impl Detector {
115 pub fn from_config(path: impl AsRef<Path>) -> Result<Self> {
116 Self::from_config_with_runtime(path, Runtime::Auto)
117 }
118
119 pub fn from_config_with_runtime(path: impl AsRef<Path>, runtime: Runtime) -> Result<Self> {
120 Self::from_parts(Config::from_file(path)?, runtime)
121 }
122
123 pub fn builder(model_path: impl Into<PathBuf>) -> DetectorBuilder {
124 DetectorBuilder::new(model_path)
125 }
126
127 pub(crate) fn from_parts(config: Config, runtime: Runtime) -> Result<Self> {
128 config.validate()?;
129 let frontend = Frontend::new()?;
130 let engine = Engine::new(&config, &runtime)?;
131 let model_rows = engine.model_rows;
132 let window = config.sliding_window_size;
133 Ok(Self {
134 config,
135 runtime,
136 frontend,
137 engine,
138 feature_rows: Vec::with_capacity(model_rows),
139 probabilities: VecDeque::with_capacity(window),
140 })
141 }
142
143 pub fn config(&self) -> &Config {
144 &self.config
145 }
146
147 pub fn process_audio(&mut self, samples: &[i16]) -> Result<Option<Detection>> {
149 let samples: &[i16; AUDIO_BLOCK_SAMPLES] = samples.try_into().map_err(|_| {
150 Error::Audio(format!(
151 "expected exactly {AUDIO_BLOCK_SAMPLES} samples of 16 kHz mono PCM, got {}",
152 samples.len()
153 ))
154 })?;
155 let Some(features) = self.frontend.process(samples)? else {
156 return Ok(None);
157 };
158 self.process_features(features)
159 }
160
161 fn process_features(&mut self, features: [u16; FEATURE_COUNT]) -> Result<Option<Detection>> {
162 self.feature_rows.push(features);
163 if self.feature_rows.len() < self.engine.model_rows {
164 return Ok(None);
165 }
166
167 {
168 let mut input = self.engine.interpreter.input_mut(0)?;
169 let destination = input.data_mut()?;
170 for (target, feature) in destination
171 .iter_mut()
172 .zip(self.feature_rows.iter().flat_map(|row| row.iter().copied()))
173 {
174 let real = feature as f32 * FEATURE_SCALE;
175 let quantized = (real / self.engine.input_scale
176 + self.engine.input_zero_point as f32)
177 .round()
178 .clamp(i8::MIN as f32, i8::MAX as f32) as i8;
179 *target = quantized as u8;
180 }
181 }
182 self.feature_rows.clear();
183 self.engine.interpreter.invoke()?;
184 let output = self.engine.interpreter.output(0)?.to_vec_f32()?;
185 let probability = output[0].clamp(0.0, 1.0);
186 self.probabilities.push_back(probability);
187 if self.probabilities.len() > self.config.sliding_window_size {
188 self.probabilities.pop_front();
189 }
190 if self.probabilities.len() < self.config.sliding_window_size {
191 return Ok(None);
192 }
193 let probability = self.probabilities.iter().sum::<f32>() / self.probabilities.len() as f32;
194 Ok(
195 passes_cutoff(probability, self.config.probability_cutoff).then(|| Detection {
196 wake_word: self.config.wake_word.clone(),
197 probability,
198 }),
199 )
200 }
201
202 pub fn reset(&mut self) -> Result<()> {
204 let frontend = Frontend::new()?;
205 let engine = Engine::new(&self.config, &self.runtime)?;
206 self.frontend = frontend;
207 self.engine = engine;
208 self.feature_rows.clear();
209 self.probabilities.clear();
210 Ok(())
211 }
212}
213
214fn passes_cutoff(probability: f32, cutoff: f32) -> bool {
215 probability > cutoff
216}
217
218pub struct DetectorBuilder {
220 model_path: PathBuf,
221 wake_word: Option<String>,
222 probability_cutoff: Option<f32>,
223 sliding_window_size: Option<usize>,
224 feature_step_size_ms: u32,
225 runtime: Runtime,
226}
227
228impl DetectorBuilder {
229 fn new(model_path: impl Into<PathBuf>) -> Self {
230 Self {
231 model_path: model_path.into(),
232 wake_word: None,
233 probability_cutoff: None,
234 sliding_window_size: None,
235 feature_step_size_ms: 10,
236 runtime: Runtime::Auto,
237 }
238 }
239
240 pub fn wake_word(mut self, value: impl Into<String>) -> Self {
241 self.wake_word = Some(value.into());
242 self
243 }
244 pub fn probability_cutoff(mut self, value: f32) -> Self {
245 self.probability_cutoff = Some(value);
246 self
247 }
248 pub fn sliding_window_size(mut self, value: usize) -> Self {
249 self.sliding_window_size = Some(value);
250 self
251 }
252 pub fn feature_step_size_ms(mut self, value: u32) -> Self {
253 self.feature_step_size_ms = value;
254 self
255 }
256 pub fn runtime(mut self, value: Runtime) -> Self {
257 self.runtime = value;
258 self
259 }
260
261 pub fn build(self) -> Result<Detector> {
262 let missing = |name| Error::InvalidConfig(format!("builder requires `{name}`"));
263 let config = Config {
264 model_path: self.model_path,
265 wake_word: self.wake_word.ok_or_else(|| missing("wake_word"))?,
266 probability_cutoff: self
267 .probability_cutoff
268 .ok_or_else(|| missing("probability_cutoff"))?,
269 sliding_window_size: self
270 .sliding_window_size
271 .ok_or_else(|| missing("sliding_window_size"))?,
272 feature_step_size_ms: self.feature_step_size_ms,
273 metadata: ModelMetadata {
274 format_version: 2,
275 ..ModelMetadata::default()
276 },
277 };
278 Detector::from_parts(config, self.runtime)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::passes_cutoff;
285
286 #[test]
287 fn cutoff_boundary_is_strict_like_micro_wakeword() {
288 assert!(!passes_cutoff(0.3, 0.3));
289 assert!(passes_cutoff(0.300_001, 0.3));
290 }
291}