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 block size. It analyzes independent five-second windows, advancing the start of each
10/// window by `step_samples`.
11///
12/// For streaming 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-1.1-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 block size would add buffering inside the collector and shift the analysis timing.
115        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            // Collector/STFT output advances at the model hop size, so always feed fixed optimal
123            // blocks regardless of the requested analysis step.
124            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        // Short files still produce one padded five-second analysis. Longer files produce one
134        // result for each complete five-second window on the step grid.
135        let num_results = window_starts.len();
136        let mut results = Vec::with_capacity(num_results);
137
138        for window_start in window_starts {
139            // Each result must be computed from an independent five-second span. Reset clears both
140            // the analyzer and collector before buffering the next window from scratch.
141            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    // Buffers exactly one analysis window into the collector using fixed-size model-hop blocks.
172    // Missing samples are zero-padded so short first windows still reach the model's full context.
173    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            // The collector was initialized with a fixed block size, so every call below must pass
191            // exactly block_size samples.
192            if available_samples == block_size {
193                // Fast path: the next fixed-size block is fully available from the source audio.
194                let block_end = block_start + block_size;
195                self.collector.buffer(&audio[block_start..block_end])?;
196            } else {
197                // Pad short windows or non-aligned tails with silence while still feeding the
198                // collector exactly one fixed-size block.
199                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    /// The only analysis model this SDK version can load.
220    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}