aic-sdk 0.24.0

ai-coustics SDK
Documentation
use crate::{AicError, AnalysisResult, Analyzer, Collector, Model, ProcessorConfig, analyzer_pair};

/// Analyzes complete mono audio buffers.
///
/// `FileAnalyzer` is a convenience wrapper around a [`Collector`] and [`Analyzer`] pair for
/// non-real-time analysis of audio that is already loaded in memory.
///
/// Each call to [`analyze`](Self::analyze) configures the collector for mono input with the model's
/// optimal block size. It analyzes independent five-second windows, advancing the start of each
/// window by `step_samples`.
///
/// For streaming analysis, use [`analyzer_pair`] directly.
pub struct FileAnalyzer<'model, 'a> {
    model: &'model Model<'a>,
    collector: Collector,
    analyzer: Analyzer<'a>,
}

impl<'model, 'a> FileAnalyzer<'model, 'a> {
    // TODO: This should be queried from the model, but there are no APIs
    // for that available yet. `tyto-1.1-l-16khz` has a fixed window size of 5 seconds.
    const ANALYSIS_WINDOW_SECONDS: usize = 5;

    /// Creates a new file analyzer.
    ///
    /// The collector is not initialized until [`analyze`](Self::analyze) is called. This lets the
    /// same `FileAnalyzer` instance analyze mono buffers with different sample rates or step sizes.
    ///
    /// # Arguments
    ///
    /// * `model` - The loaded model instance
    /// * `license_key` - license key for the ai-coustics SDK
    ///   (generate your key at [developers.ai-coustics.com](https://developers.ai-coustics.com/))
    ///
    /// # Returns
    ///
    /// Returns a `FileAnalyzer` if the analyzer pair can be created, otherwise an [`AicError`].
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use aic_sdk::{FileAnalyzer, Model};
    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
    /// let mut analyzer = FileAnalyzer::new(&model, &license_key)?;
    ///
    /// let sample_rate = 16_000;
    /// let audio = vec![0.0f32; 8000];
    /// let results = analyzer.analyze(&audio, sample_rate, None)?;
    /// # Ok::<(), aic_sdk::AicError>(())
    /// ```
    pub fn new(model: &'model Model<'a>, license_key: &str) -> Result<Self, AicError> {
        let (collector, analyzer) = analyzer_pair(model, license_key)?;

        Ok(Self {
            model,
            collector,
            analyzer,
        })
    }

    /// Analyzes a complete mono audio buffer.
    ///
    /// The input slice must contain mono `f32` samples at `sample_rate`. No channel mixing or
    /// resampling is performed.
    ///
    /// The analyzer evaluates five-second windows. `FileAnalyzer` buffers a window starting at
    /// sample 0, runs the analyzer once, resets the analyzer and collector, then repeats with a
    /// window starting `step_samples` later.
    ///
    /// If `audio` is shorter than or equal to five seconds, it is padded with silence and only one
    /// result is returned. For longer signals, only complete five-second windows are analyzed after
    /// the first window.
    ///
    /// # Arguments
    ///
    /// * `audio` - Mono audio samples to analyze
    /// * `sample_rate` - Sample rate of `audio` in Hz
    /// * `step_samples` - Number of samples to advance between analysis results. Defaults to
    ///   the model's window size (no overlap in analysis windows) if `None`.
    ///
    /// # Returns
    ///
    /// Returns a list of [`AnalysisResult`] values, or an [`AicError`] if initialization,
    /// buffering, or analysis fails.
    ///
    /// # Real-time safety
    ///
    /// This function is not real-time safe. Avoid calling it from audio threads.
    pub fn analyze(
        &mut self,
        audio: &[f32],
        sample_rate: u32,
        step_samples: Option<usize>,
    ) -> Result<Vec<AnalysisResult>, AicError> {
        if sample_rate == 0 {
            return Err(AicError::AudioConfigUnsupported);
        }

        // The analysis model consumes a fixed five-second context. Convert that duration to the
        // caller's sample rate once and use it as the size of every analysis window.
        let Some(analysis_window_samples) =
            (sample_rate as usize).checked_mul(Self::ANALYSIS_WINDOW_SECONDS)
        else {
            return Err(AicError::AudioConfigUnsupported);
        };

        let step_samples = step_samples.unwrap_or(analysis_window_samples);
        if step_samples == 0 {
            return Err(AicError::AudioConfigUnsupported);
        }

        // The collector only emits fresh spectrogram frames at the model's hop size. Feeding any
        // other block size would add buffering inside the collector and shift the analysis timing.
        let optimal_block_size = self.model.optimal_block_size(sample_rate);
        if optimal_block_size == 0 {
            return Err(AicError::AudioConfigUnsupported);
        }

        let config = ProcessorConfig {
            sample_rate,
            // Collector/STFT output advances at the model hop size, so always feed fixed optimal
            // blocks regardless of the requested analysis step.
            block_size: optimal_block_size,
            variable_block_size: false,
        };

        self.collector.initialize(&config)?;

        let window_starts =
            Self::analysis_window_starts(audio.len(), analysis_window_samples, step_samples);

        // Short files still produce one padded five-second analysis. Longer files produce one
        // result for each complete five-second window on the step grid.
        let num_results = window_starts.len();
        let mut results = Vec::with_capacity(num_results);

        for window_start in window_starts {
            // Each result must be computed from an independent five-second span. Reset clears both
            // the analyzer and collector before buffering the next window from scratch.
            self.analyzer.reset()?;

            self.buffer_analysis_window(
                audio,
                window_start,
                analysis_window_samples,
                optimal_block_size,
            )?;

            results.push(self.analyzer.analyze_buffered()?);
        }

        Ok(results)
    }

    fn analysis_window_starts(
        audio_len: usize,
        analysis_window_samples: usize,
        step_samples: usize,
    ) -> Vec<usize> {
        if audio_len <= analysis_window_samples {
            return vec![0];
        }

        let num_complete_followup_windows = (audio_len - analysis_window_samples) / step_samples;
        (0..=num_complete_followup_windows)
            .map(|step| step * step_samples)
            .collect()
    }

    // Buffers exactly one analysis window into the collector using fixed-size model-hop blocks.
    // Missing samples are zero-padded so short first windows still reach the model's full context.
    fn buffer_analysis_window(
        &mut self,
        audio: &[f32],
        start: usize,
        window_samples: usize,
        block_size: usize,
    ) -> Result<(), AicError> {
        let mut block = vec![0.0; block_size];
        let mut buffered_samples = 0;

        while buffered_samples < window_samples {
            let Some(block_start) = start.checked_add(buffered_samples) else {
                return Err(AicError::AudioConfigUnsupported);
            };

            let available_samples = audio.len().saturating_sub(block_start).min(block_size);

            // The collector was initialized with a fixed block size, so every call below must pass
            // exactly block_size samples.
            if available_samples == block_size {
                // Fast path: the next fixed-size block is fully available from the source audio.
                let block_end = block_start + block_size;
                self.collector.buffer(&audio[block_start..block_end])?;
            } else {
                // Pad short windows or non-aligned tails with silence while still feeding the
                // collector exactly one fixed-size block.
                block.fill(0.0);
                if available_samples > 0 {
                    let block_end = block_start + available_samples;
                    block[..available_samples].copy_from_slice(&audio[block_start..block_end]);
                }
                self.collector.buffer(&block)?;
            }

            buffered_samples += block_size;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::{license_key, test_model_path};

    /// The only analysis model this SDK version can load.
    const TEST_MODEL_ID: &str = "tyto-1.1-l-16khz";

    fn load_test_model() -> Result<(Model<'static>, String), AicError> {
        let model = Model::from_file(test_model_path(TEST_MODEL_ID))?;

        Ok((model, license_key()))
    }

    fn assert_score_range(result: &AnalysisResult) {
        assert!((0.0..=1.0).contains(&result.risk_score));
        assert!((0.0..=1.0).contains(&result.speaker_reverb));
        assert!((0.0..=1.0).contains(&result.speaker_loudness));
        assert!((0.0..=1.0).contains(&result.interfering_speech));
        assert!((0.0..=1.0).contains(&result.noise));
        assert!((0.0..=1.0).contains(&result.codec_degradation));
        assert!((0.0..=1.0).contains(&result.packet_loss));
    }

    fn assert_all_scores_in_range(results: &[AnalysisResult]) {
        for result in results {
            assert_score_range(result);
        }
    }

    #[test]
    fn analysis_window_starts_returns_one_padded_window_for_short_audio() {
        assert_eq!(FileAnalyzer::analysis_window_starts(0, 80_000, 1_600), [0]);
        assert_eq!(
            FileAnalyzer::analysis_window_starts(79_999, 80_000, 1_600),
            [0]
        );
        assert_eq!(
            FileAnalyzer::analysis_window_starts(80_000, 80_000, 1_600),
            [0]
        );
    }

    #[test]
    fn analysis_window_starts_advances_by_step_for_complete_followup_windows() {
        assert_eq!(
            FileAnalyzer::analysis_window_starts(83_200, 80_000, 1_600),
            [0, 1_600, 3_200]
        );
        assert_eq!(
            FileAnalyzer::analysis_window_starts(86_400, 80_000, 1_600),
            [0, 1_600, 3_200, 4_800, 6_400]
        );
    }

    #[test]
    fn analysis_window_starts_ignores_partial_followup_windows() {
        assert_eq!(
            FileAnalyzer::analysis_window_starts(81_599, 80_000, 1_600),
            [0]
        );
        assert_eq!(
            FileAnalyzer::analysis_window_starts(83_199, 80_000, 1_600),
            [0, 1_600]
        );
    }

    #[test]
    fn new_rejects_license_key_with_nul() {
        let (model, _) = load_test_model().unwrap();

        let result = FileAnalyzer::new(&model, "invalid\0license");

        assert!(matches!(result, Err(AicError::LicenseFormatInvalid)));
    }

    #[test]
    fn analyze_rejects_zero_sample_rate_or_step_size() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let audio = [0.0f32; 16];

        assert_eq!(
            analyzer.analyze(&audio, 0, Some(160)),
            Err(AicError::AudioConfigUnsupported)
        );
        assert_eq!(
            analyzer.analyze(&audio, 16_000, Some(0)),
            Err(AicError::AudioConfigUnsupported)
        );
    }

    #[test]
    fn analyze_short_audio_returns_single_padded_result() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let sample_rate = model.optimal_sample_rate();
        let step_samples = model.optimal_block_size(sample_rate);
        let audio = vec![0.0f32; sample_rate as usize];

        let results = analyzer
            .analyze(&audio, sample_rate, Some(step_samples))
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_all_scores_in_range(&results);
    }

    #[test]
    fn analyze_exact_window_returns_single_result() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let sample_rate = model.optimal_sample_rate();
        let step_samples = model.optimal_block_size(sample_rate);
        let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
        let audio = vec![0.0f32; window_samples];

        let results = analyzer
            .analyze(&audio, sample_rate, Some(step_samples))
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_all_scores_in_range(&results);
    }

    #[test]
    fn analyze_defaults_step_to_analysis_window_size() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let sample_rate = model.optimal_sample_rate();
        let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
        let audio = vec![0.0f32; window_samples * 2];

        let results = analyzer.analyze(&audio, sample_rate, None).unwrap();

        assert_eq!(results.len(), 2);
        assert_all_scores_in_range(&results);
    }

    #[test]
    fn analyze_long_audio_returns_one_result_per_complete_window() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let sample_rate = model.optimal_sample_rate();
        let step_samples = model.optimal_block_size(sample_rate);
        let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
        let audio = vec![0.0f32; window_samples + 2 * step_samples];

        let results = analyzer
            .analyze(&audio, sample_rate, Some(step_samples))
            .unwrap();

        assert_eq!(results.len(), 3);
        assert_all_scores_in_range(&results);
    }

    #[test]
    fn analyze_ignores_partial_followup_window() {
        let (model, license_key) = load_test_model().unwrap();
        let mut analyzer = FileAnalyzer::new(&model, &license_key).unwrap();
        let sample_rate = model.optimal_sample_rate();
        let step_samples = model.optimal_block_size(sample_rate);
        let window_samples = sample_rate as usize * FileAnalyzer::ANALYSIS_WINDOW_SECONDS;
        let audio = vec![0.0f32; window_samples + step_samples - 1];

        let results = analyzer
            .analyze(&audio, sample_rate, Some(step_samples))
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_all_scores_in_range(&results);
    }
}