Skip to main content

aic_sdk/
file_analyzer.rs

1use crate::{AicError, AnalysisResult, Analyzer, Collector, Model, ProcessorConfig, analyzer_pair};
2
3/// Analyzes complete mono audio buffers.
4///
5/// `FileAnalyzer` is a convenience wrapper around a [`Collector`] and [`Analyzer`] pair for
6/// non-real-time analysis of audio that is already loaded in memory.
7///
8/// Each call to [`analyze`](Self::analyze) configures the collector for mono input with the model's
9/// optimal frame size. It analyzes independent five-second windows, advancing the start of each
10/// window by `step_samples`.
11///
12/// For streaming or multi-channel analysis, use [`analyzer_pair`] directly.
13pub 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    // TODO: This should be queried from the model, but there are no APIs
21    // for that available yet. `tyto-l-16khz` has a fixed window size of 5 seconds.
22    const ANALYSIS_WINDOW_SECONDS: usize = 5;
23
24    /// Creates a new file analyzer.
25    ///
26    /// The collector is not initialized until [`analyze`](Self::analyze) is called. This lets the
27    /// same `FileAnalyzer` instance analyze mono buffers with different sample rates or step sizes.
28    ///
29    /// # Arguments
30    ///
31    /// * `model` - The loaded model instance
32    /// * `license_key` - license key for the ai-coustics SDK
33    ///   (generate your key at [developers.ai-coustics.com](https://developers.ai-coustics.com/))
34    ///
35    /// # Returns
36    ///
37    /// Returns a `FileAnalyzer` if the analyzer pair can be created, otherwise an [`AicError`].
38    ///
39    /// # Example
40    ///
41    /// ```rust,no_run
42    /// # use aic_sdk::{FileAnalyzer, Model};
43    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
44    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
45    /// let mut analyzer = FileAnalyzer::new(&model, &license_key)?;
46    ///
47    /// let sample_rate = 16_000;
48    /// let audio = vec![0.0f32; 8000];
49    /// let results = analyzer.analyze(&audio, sample_rate, None)?;
50    /// # Ok::<(), aic_sdk::AicError>(())
51    /// ```
52    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    /// Analyzes a complete mono audio buffer.
63    ///
64    /// The input slice must contain mono `f32` samples at `sample_rate`. No channel mixing or
65    /// resampling is performed.
66    ///
67    /// The analyzer evaluates five-second windows. `FileAnalyzer` buffers a window starting at
68    /// sample 0, runs the analyzer once, resets the analyzer and collector, then repeats with a
69    /// window starting `step_samples` later.
70    ///
71    /// If `audio` is shorter than or equal to five seconds, it is padded with silence and only one
72    /// result is returned. For longer signals, only complete five-second windows are analyzed after
73    /// the first window.
74    ///
75    /// # Arguments
76    ///
77    /// * `audio` - Mono audio samples to analyze
78    /// * `sample_rate` - Sample rate of `audio` in Hz
79    /// * `step_samples` - Number of samples to advance between analysis results. Defaults to
80    ///   the model's window size (no overlap in analysis windows) if `None`.
81    ///
82    /// # Returns
83    ///
84    /// Returns a list of [`AnalysisResult`] values, or an [`AicError`] if initialization,
85    /// buffering, or analysis fails.
86    ///
87    /// # Real-time safety
88    ///
89    /// This function is not real-time safe. Avoid calling it from audio threads.
90    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        // The analysis model consumes a fixed five-second context. Convert that duration to the
101        // caller's sample rate once and use it as the size of every analysis window.
102        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        // The collector only emits fresh spectrogram frames at the model's hop size. Feeding any
114        // other frame size would add buffering inside the collector and shift the analysis timing.
115        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            // Collector/STFT output advances at the model hop size, so always feed fixed optimal
124            // frames regardless of the requested analysis step.
125            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        // Short files still produce one padded five-second analysis. Longer files produce one
135        // result for each complete five-second window on the step grid.
136        let num_results = window_starts.len();
137        let mut results = Vec::with_capacity(num_results);
138
139        for window_start in window_starts {
140            // Each result must be computed from an independent five-second span. Reset clears both
141            // the analyzer and collector before buffering the next window from scratch.
142            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    // Buffers exactly one analysis window into the collector using fixed-size model-hop frames.
173    // Missing samples are zero-padded so short first windows still reach the model's full context.
174    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            // The collector was initialized with fixed frame size, so every call below must pass
192            // exactly frame_samples samples.
193            if available_samples == frame_samples {
194                // Fast path: the next fixed-size frame is fully available from the source audio.
195                let frame_end = frame_start + frame_samples;
196                self.collector
197                    .buffer_interleaved(&audio[frame_start..frame_end])?;
198            } else {
199                // Pad short windows or non-aligned tails with silence while still feeding the
200                // collector exactly one fixed-size frame.
201                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}