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_num_frames = self.model.optimal_num_frames(sample_rate);
116 if optimal_num_frames == 0 {
117 return Err(AicError::AudioConfigUnsupported);
118 }
119
120 let config = ProcessorConfig {
121 sample_rate,
122 num_channels: 1,
123 num_frames: optimal_num_frames,
126 allow_variable_frames: false,
127 };
128
129 self.collector.initialize(&config)?;
130
131 let window_starts =
132 Self::analysis_window_starts(audio.len(), analysis_window_samples, step_samples);
133
134 let num_results = window_starts.len();
137 let mut results = Vec::with_capacity(num_results);
138
139 for window_start in window_starts {
140 self.analyzer.reset()?;
143
144 self.buffer_analysis_window(
145 audio,
146 window_start,
147 analysis_window_samples,
148 optimal_num_frames,
149 )?;
150
151 results.push(self.analyzer.analyze_buffered()?);
152 }
153
154 Ok(results)
155 }
156
157 fn analysis_window_starts(
158 audio_len: usize,
159 analysis_window_samples: usize,
160 step_samples: usize,
161 ) -> Vec<usize> {
162 if audio_len <= analysis_window_samples {
163 return vec![0];
164 }
165
166 let num_complete_followup_windows = (audio_len - analysis_window_samples) / step_samples;
167 (0..=num_complete_followup_windows)
168 .map(|step| step * step_samples)
169 .collect()
170 }
171
172 fn buffer_analysis_window(
175 &mut self,
176 audio: &[f32],
177 start: usize,
178 window_samples: usize,
179 frame_samples: usize,
180 ) -> Result<(), AicError> {
181 let mut frame = vec![0.0; frame_samples];
182 let mut buffered_samples = 0;
183
184 while buffered_samples < window_samples {
185 let Some(frame_start) = start.checked_add(buffered_samples) else {
186 return Err(AicError::AudioConfigUnsupported);
187 };
188
189 let available_samples = audio.len().saturating_sub(frame_start).min(frame_samples);
190
191 if available_samples == frame_samples {
194 let frame_end = frame_start + frame_samples;
196 self.collector
197 .buffer_interleaved(&audio[frame_start..frame_end])?;
198 } else {
199 frame.fill(0.0);
202 if available_samples > 0 {
203 let frame_end = frame_start + available_samples;
204 frame[..available_samples].copy_from_slice(&audio[frame_start..frame_end]);
205 }
206 self.collector.buffer_interleaved(&frame)?;
207 }
208
209 buffered_samples += frame_samples;
210 }
211
212 Ok(())
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use std::{
220 fs,
221 path::{Path, PathBuf},
222 sync::{Mutex, OnceLock},
223 };
224
225 fn download_lock() -> &'static Mutex<()> {
226 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
227 LOCK.get_or_init(|| Mutex::new(()))
228 }
229
230 fn find_existing_model(target_dir: &Path) -> Option<PathBuf> {
231 let entries = fs::read_dir(target_dir).ok()?;
232 for entry in entries.flatten() {
233 let path = entry.path();
234 if path
235 .file_name()
236 .and_then(|n| n.to_str())
237 .map(|name| name.contains("tyto_l_16khz") && name.ends_with(".aicmodel"))
238 .unwrap_or(false)
239 && path.is_file()
240 {
241 return Some(path);
242 }
243 }
244 None
245 }
246
247 fn get_tyto_l_16khz() -> Result<PathBuf, AicError> {
248 let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
249
250 if let Some(existing) = find_existing_model(&target_dir) {
251 return Ok(existing);
252 }
253
254 let _guard = download_lock().lock().unwrap();
255 if let Some(existing) = find_existing_model(&target_dir) {
256 return Ok(existing);
257 }
258
259 if cfg!(feature = "download-model") {
260 Model::download("tyto-l-16khz", target_dir)
261 } else {
262 panic!(
263 "Model `tyto-l-16khz` not found in {} and `download-model` feature is disabled",
264 target_dir.display()
265 );
266 }
267 }
268
269 fn load_test_model() -> Result<(Model<'static>, String), AicError> {
270 let license_key = std::env::var("AIC_SDK_LICENSE")
271 .expect("AIC_SDK_LICENSE environment variable must be set for tests");
272
273 let model_path = get_tyto_l_16khz()?;
274 let model = Model::from_file(&model_path)?;
275
276 Ok((model, license_key))
277 }
278
279 fn assert_score_range(result: &AnalysisResult) {
280 assert!((0.0..=1.0).contains(&result.risk_score));
281 assert!((0.0..=1.0).contains(&result.speaker_reverb));
282 assert!((0.0..=1.0).contains(&result.speaker_loudness));
283 assert!((0.0..=1.0).contains(&result.interfering_speech));
284 assert!((0.0..=1.0).contains(&result.media_speech));
285 assert!((0.0..=1.0).contains(&result.noise));
286 assert!((0.0..=1.0).contains(&result.packet_loss));
287 }
288
289 fn assert_all_scores_in_range(results: &[AnalysisResult]) {
290 for result in results {
291 assert_score_range(result);
292 }
293 }
294
295 #[test]
296 fn analysis_window_starts_returns_one_padded_window_for_short_audio() {
297 assert_eq!(FileAnalyzer::analysis_window_starts(0, 80_000, 1_600), [0]);
298 assert_eq!(
299 FileAnalyzer::analysis_window_starts(79_999, 80_000, 1_600),
300 [0]
301 );
302 assert_eq!(
303 FileAnalyzer::analysis_window_starts(80_000, 80_000, 1_600),
304 [0]
305 );
306 }
307
308 #[test]
309 fn analysis_window_starts_advances_by_step_for_complete_followup_windows() {
310 assert_eq!(
311 FileAnalyzer::analysis_window_starts(83_200, 80_000, 1_600),
312 [0, 1_600, 3_200]
313 );
314 assert_eq!(
315 FileAnalyzer::analysis_window_starts(86_400, 80_000, 1_600),
316 [0, 1_600, 3_200, 4_800, 6_400]
317 );
318 }
319
320 #[test]
321 fn analysis_window_starts_ignores_partial_followup_windows() {
322 assert_eq!(
323 FileAnalyzer::analysis_window_starts(81_599, 80_000, 1_600),
324 [0]
325 );
326 assert_eq!(
327 FileAnalyzer::analysis_window_starts(83_199, 80_000, 1_600),
328 [0, 1_600]
329 );
330 }
331
332 #[test]
333 fn new_rejects_license_key_with_nul() {
334 let (model, _) = load_test_model().unwrap();
335
336 let result = FileAnalyzer::new(&model, "invalid\0license");
337
338 assert!(matches!(result, Err(AicError::LicenseFormatInvalid)));
339 }
340
341 #[test]
342 fn analyze_rejects_zero_sample_rate_or_step_size() {
343 let (model, license_key) = load_test_model().unwrap();
344 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
345 let audio = [0.0f32; 16];
346
347 assert_eq!(
348 analyzer.analyze(&audio, 0, Some(160)),
349 Err(AicError::AudioConfigUnsupported)
350 );
351 assert_eq!(
352 analyzer.analyze(&audio, 16_000, Some(0)),
353 Err(AicError::AudioConfigUnsupported)
354 );
355 }
356
357 #[test]
358 fn analyze_short_audio_returns_single_padded_result() {
359 let (model, license_key) = load_test_model().unwrap();
360 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
361 let sample_rate = model.optimal_sample_rate();
362 let step_samples = model.optimal_num_frames(sample_rate);
363 let audio = vec![0.0f32; sample_rate as usize];
364
365 let results = analyzer
366 .analyze(&audio, sample_rate, Some(step_samples))
367 .unwrap();
368
369 assert_eq!(results.len(), 1);
370 assert_all_scores_in_range(&results);
371 }
372
373 #[test]
374 fn analyze_exact_window_returns_single_result() {
375 let (model, license_key) = load_test_model().unwrap();
376 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
377 let sample_rate = model.optimal_sample_rate();
378 let step_samples = model.optimal_num_frames(sample_rate);
379 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
380 let audio = vec![0.0f32; window_samples];
381
382 let results = analyzer
383 .analyze(&audio, sample_rate, Some(step_samples))
384 .unwrap();
385
386 assert_eq!(results.len(), 1);
387 assert_all_scores_in_range(&results);
388 }
389
390 #[test]
391 fn analyze_defaults_step_to_analysis_window_size() {
392 let (model, license_key) = load_test_model().unwrap();
393 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
394 let sample_rate = model.optimal_sample_rate();
395 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
396 let audio = vec![0.0f32; window_samples * 2];
397
398 let results = analyzer.analyze(&audio, sample_rate, None).unwrap();
399
400 assert_eq!(results.len(), 2);
401 assert_all_scores_in_range(&results);
402 }
403
404 #[test]
405 fn analyze_long_audio_returns_one_result_per_complete_window() {
406 let (model, license_key) = load_test_model().unwrap();
407 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
408 let sample_rate = model.optimal_sample_rate();
409 let step_samples = model.optimal_num_frames(sample_rate);
410 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
411 let audio = vec![0.0f32; window_samples + 2 * step_samples];
412
413 let results = analyzer
414 .analyze(&audio, sample_rate, Some(step_samples))
415 .unwrap();
416
417 assert_eq!(results.len(), 3);
418 assert_all_scores_in_range(&results);
419 }
420
421 #[test]
422 fn analyze_ignores_partial_followup_window() {
423 let (model, license_key) = load_test_model().unwrap();
424 let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
425 let sample_rate = model.optimal_sample_rate();
426 let step_samples = model.optimal_num_frames(sample_rate);
427 let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
428 let audio = vec![0.0f32; window_samples + step_samples - 1];
429
430 let results = analyzer
431 .analyze(&audio, sample_rate, Some(step_samples))
432 .unwrap();
433
434 assert_eq!(results.len(), 1);
435 assert_all_scores_in_range(&results);
436 }
437}