1use crate::{AicError, AnalysisResult, Analyzer, Collector, Model, ProcessorConfig, analyzer_pair};
2
3pub struct FileAnalyzer<'model, 'a> {
14 model: &'model Model<'a>,
15 collector: Collector,
16 analyzer: Analyzer<'a>,
17}
18
19impl<'model, 'a> FileAnalyzer<'model, 'a> {
20 const ANALYSIS_WINDOW_SECONDS: usize = 5;
23
24 pub fn new(model: &'model Model<'a>, license_key: &str) -> Result<Self, AicError> {
53 let (collector, analyzer) = analyzer_pair(model, license_key)?;
54
55 Ok(Self {
56 model,
57 collector,
58 analyzer,
59 })
60 }
61
62 pub fn analyze(
91 &mut self,
92 audio: &[f32],
93 sample_rate: u32,
94 step_samples: Option<usize>,
95 ) -> Result<Vec<AnalysisResult>, AicError> {
96 if sample_rate == 0 {
97 return Err(AicError::AudioConfigUnsupported);
98 }
99
100 let Some(analysis_window_samples) =
103 (sample_rate as usize).checked_mul(Self::ANALYSIS_WINDOW_SECONDS)
104 else {
105 return Err(AicError::AudioConfigUnsupported);
106 };
107
108 let step_samples = step_samples.unwrap_or(analysis_window_samples);
109 if step_samples == 0 {
110 return Err(AicError::AudioConfigUnsupported);
111 }
112
113 let optimal_block_size = self.model.optimal_block_size(sample_rate);
116 if optimal_block_size == 0 {
117 return Err(AicError::AudioConfigUnsupported);
118 }
119
120 let config = ProcessorConfig {
121 sample_rate,
122 block_size: optimal_block_size,
125 variable_block_size: false,
126 };
127
128 self.collector.initialize(&config)?;
129
130 let window_starts =
131 Self::analysis_window_starts(audio.len(), analysis_window_samples, step_samples);
132
133 let num_results = window_starts.len();
136 let mut results = Vec::with_capacity(num_results);
137
138 for window_start in window_starts {
139 self.analyzer.reset()?;
142
143 self.buffer_analysis_window(
144 audio,
145 window_start,
146 analysis_window_samples,
147 optimal_block_size,
148 )?;
149
150 results.push(self.analyzer.analyze_buffered()?);
151 }
152
153 Ok(results)
154 }
155
156 fn analysis_window_starts(
157 audio_len: usize,
158 analysis_window_samples: usize,
159 step_samples: usize,
160 ) -> Vec<usize> {
161 if audio_len <= analysis_window_samples {
162 return vec![0];
163 }
164
165 let num_complete_followup_windows = (audio_len - analysis_window_samples) / step_samples;
166 (0..=num_complete_followup_windows)
167 .map(|step| step * step_samples)
168 .collect()
169 }
170
171 fn buffer_analysis_window(
174 &mut self,
175 audio: &[f32],
176 start: usize,
177 window_samples: usize,
178 block_size: usize,
179 ) -> Result<(), AicError> {
180 let mut block = vec![0.0; block_size];
181 let mut buffered_samples = 0;
182
183 while buffered_samples < window_samples {
184 let Some(block_start) = start.checked_add(buffered_samples) else {
185 return Err(AicError::AudioConfigUnsupported);
186 };
187
188 let available_samples = audio.len().saturating_sub(block_start).min(block_size);
189
190 if available_samples == block_size {
193 let block_end = block_start + block_size;
195 self.collector.buffer(&audio[block_start..block_end])?;
196 } else {
197 block.fill(0.0);
200 if available_samples > 0 {
201 let block_end = block_start + available_samples;
202 block[..available_samples].copy_from_slice(&audio[block_start..block_end]);
203 }
204 self.collector.buffer(&block)?;
205 }
206
207 buffered_samples += block_size;
208 }
209
210 Ok(())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::test_support::{license_key, test_model_path};
218
219 const TEST_MODEL_ID: &str = "tyto-1.1-l-16khz";
221
222 fn load_test_model() -> Result<(Model<'static>, String), AicError> {
223 let model = Model::from_file(test_model_path(TEST_MODEL_ID))?;
224
225 Ok((model, license_key()))
226 }
227
228 fn assert_score_range(result: &AnalysisResult) {
229 assert!((0.0..=1.0).contains(&result.risk_score));
230 assert!((0.0..=1.0).contains(&result.speaker_reverb));
231 assert!((0.0..=1.0).contains(&result.speaker_loudness));
232 assert!((0.0..=1.0).contains(&result.interfering_speech));
233 assert!((0.0..=1.0).contains(&result.noise));
234 assert!((0.0..=1.0).contains(&result.codec_degradation));
235 assert!((0.0..=1.0).contains(&result.packet_loss));
236 }
237
238 fn assert_all_scores_in_range(results: &[AnalysisResult]) {
239 for result in results {
240 assert_score_range(result);
241 }
242 }
243
244 #[test]
245 fn analysis_window_starts_returns_one_padded_window_for_short_audio() {
246 assert_eq!(FileAnalyzer::analysis_window_starts(0, 80_000, 1_600), [0]);
247 assert_eq!(
248 FileAnalyzer::analysis_window_starts(79_999, 80_000, 1_600),
249 [0]
250 );
251 assert_eq!(
252 FileAnalyzer::analysis_window_starts(80_000, 80_000, 1_600),
253 [0]
254 );
255 }
256
257 #[test]
258 fn analysis_window_starts_advances_by_step_for_complete_followup_windows() {
259 assert_eq!(
260 FileAnalyzer::analysis_window_starts(83_200, 80_000, 1_600),
261 [0, 1_600, 3_200]
262 );
263 assert_eq!(
264 FileAnalyzer::analysis_window_starts(86_400, 80_000, 1_600),
265 [0, 1_600, 3_200, 4_800, 6_400]
266 );
267 }
268
269 #[test]
270 fn analysis_window_starts_ignores_partial_followup_windows() {
271 assert_eq!(
272 FileAnalyzer::analysis_window_starts(81_599, 80_000, 1_600),
273 [0]
274 );
275 assert_eq!(
276 FileAnalyzer::analysis_window_starts(83_199, 80_000, 1_600),
277 [0, 1_600]
278 );
279 }
280
281 #[test]
282 fn new_rejects_license_key_with_nul() {
283 let (model, _) = load_test_model().unwrap();
284
285 let result = FileAnalyzer::new(&model, "invalid\0license");
286
287 assert!(matches!(result, Err(AicError::LicenseFormatInvalid)));
288 }
289
290 #[test]
291 fn analyze_rejects_zero_sample_rate_or_step_size() {
292 let (model, license_key) = load_test_model().unwrap();
293 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
294 let audio = [0.0f32; 16];
295
296 assert_eq!(
297 analyzer.analyze(&audio, 0, Some(160)),
298 Err(AicError::AudioConfigUnsupported)
299 );
300 assert_eq!(
301 analyzer.analyze(&audio, 16_000, Some(0)),
302 Err(AicError::AudioConfigUnsupported)
303 );
304 }
305
306 #[test]
307 fn analyze_short_audio_returns_single_padded_result() {
308 let (model, license_key) = load_test_model().unwrap();
309 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
310 let sample_rate = model.optimal_sample_rate();
311 let step_samples = model.optimal_block_size(sample_rate);
312 let audio = vec![0.0f32; sample_rate as usize];
313
314 let results = analyzer
315 .analyze(&audio, sample_rate, Some(step_samples))
316 .unwrap();
317
318 assert_eq!(results.len(), 1);
319 assert_all_scores_in_range(&results);
320 }
321
322 #[test]
323 fn analyze_exact_window_returns_single_result() {
324 let (model, license_key) = load_test_model().unwrap();
325 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
326 let sample_rate = model.optimal_sample_rate();
327 let step_samples = model.optimal_block_size(sample_rate);
328 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
329 let audio = vec![0.0f32; window_samples];
330
331 let results = analyzer
332 .analyze(&audio, sample_rate, Some(step_samples))
333 .unwrap();
334
335 assert_eq!(results.len(), 1);
336 assert_all_scores_in_range(&results);
337 }
338
339 #[test]
340 fn analyze_defaults_step_to_analysis_window_size() {
341 let (model, license_key) = load_test_model().unwrap();
342 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
343 let sample_rate = model.optimal_sample_rate();
344 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
345 let audio = vec![0.0f32; window_samples * 2];
346
347 let results = analyzer.analyze(&audio, sample_rate, None).unwrap();
348
349 assert_eq!(results.len(), 2);
350 assert_all_scores_in_range(&results);
351 }
352
353 #[test]
354 fn analyze_long_audio_returns_one_result_per_complete_window() {
355 let (model, license_key) = load_test_model().unwrap();
356 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
357 let sample_rate = model.optimal_sample_rate();
358 let step_samples = model.optimal_block_size(sample_rate);
359 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
360 let audio = vec![0.0f32; window_samples + 2 * step_samples];
361
362 let results = analyzer
363 .analyze(&audio, sample_rate, Some(step_samples))
364 .unwrap();
365
366 assert_eq!(results.len(), 3);
367 assert_all_scores_in_range(&results);
368 }
369
370 #[test]
371 fn analyze_ignores_partial_followup_window() {
372 let (model, license_key) = load_test_model().unwrap();
373 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
374 let sample_rate = model.optimal_sample_rate();
375 let step_samples = model.optimal_block_size(sample_rate);
376 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
377 let audio = vec![0.0f32; window_samples + step_samples - 1];
378
379 let results = analyzer
380 .analyze(&audio, sample_rate, Some(step_samples))
381 .unwrap();
382
383 assert_eq!(results.len(), 1);
384 assert_all_scores_in_range(&results);
385 }
386}