Skip to main content

aic_sdk/
analyzer.rs

1use crate::{error::*, model::Model, processor::ProcessorConfig};
2
3use aic_sdk_sys::*;
4
5use std::{ffi::CString, marker::PhantomData, ptr};
6
7/// The result of analyzing an audio signal with an [`Analyzer`].
8///
9/// Scores are in the range `0.0..=1.0`. For all fields except
10/// [`speaker_loudness`](Self::speaker_loudness), lower values indicate less problematic audio.
11#[derive(Debug, Clone, PartialEq)]
12pub struct AnalysisResult {
13    /// Headline audio score.
14    ///
15    /// Predicts likelihood of failure of downstream models including speech-to-text,
16    /// voice activity detection or turn-taking or speech-to-speech models.
17    /// Lower indicates less problematic audio.
18    ///
19    /// **Range:** 0.0 to 1.0
20    pub risk_score: f32,
21    /// Measure of speaker distance and reverberance.
22    /// Lower indicates less problematic audio.
23    ///
24    /// **Range:** 0.0 to 1.0
25    pub speaker_reverb: f32,
26    /// Measure of speaker loudness.
27    ///
28    /// **Range:** 0.0 to 1.0
29    pub speaker_loudness: f32,
30    /// Measure of interference from additional speakers present in audio.
31    /// Lower indicates less problematic audio.
32    ///
33    /// **Range:** 0.0 to 1.0
34    pub interfering_speech: f32,
35    /// Measure of interfering speech content from media devices,
36    /// e.g. from TVs, radios, phones or else.
37    /// Lower indicates less problematic audio.
38    ///
39    /// **Range:** 0.0 to 1.0
40    pub media_speech: f32,
41    /// Measure of ambient or environmental noise.
42    /// Lower indicates less problematic audio.
43    ///
44    /// **Range:** 0.0 to 1.0
45    pub noise: f32,
46    /// Measure of audio dropouts or discontinuities in the stream,
47    /// e.g. from packet loss, frame erasure, jitter or CPU overload.
48    /// Lower indicates less problematic audio.
49    ///
50    /// **Range:** 0.0 to 1.0
51    pub packet_loss: f32,
52}
53
54impl From<AicAnalysisResult> for AnalysisResult {
55    fn from(value: AicAnalysisResult) -> Self {
56        Self {
57            risk_score: value.risk_score,
58            speaker_reverb: value.speaker_reverb,
59            speaker_loudness: value.speaker_loudness,
60            interfering_speech: value.interfering_speech,
61            media_speech: value.media_speech,
62            noise: value.noise,
63            packet_loss: value.packet_loss,
64        }
65    }
66}
67
68/// Creates a collector/analyzer pair for non-real-time analysis.
69///
70/// The collector is designed to be placed in the audio thread, buffering audio chunks for
71/// later analysis.
72///
73/// The analyzer is designed to be run separately. Analysis models are computationally expensive
74/// and cannot run in the audio thread. The analyzer has access to the audio buffered by the
75/// collector, and it can access it safely across threads.
76///
77/// The collector retains a span of audio determined by the analysis model. As more samples
78/// get collected, old audio is discarded.
79///
80/// # Arguments
81///
82/// * `model` - The loaded model instance
83/// * `license_key` - license key for the ai-coustics SDK
84///   (generate your key at [developers.ai-coustics.com](https://developers.ai-coustics.com/))
85///
86/// # Example
87///
88/// ```rust,no_run
89/// # use aic_sdk::Model;
90/// let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
91/// let model = Model::from_file("/path/to/model.aicmodel")?;
92/// let (mut collector, mut analyzer) = aic_sdk::analyzer_pair(&model, &license_key)?;
93/// # Ok::<(), aic_sdk::AicError>(())
94/// ```
95pub fn analyzer_pair<'a>(
96    model: &Model<'a>,
97    license_key: &str,
98) -> Result<(Collector, Analyzer<'a>), AicError> {
99    // Set the wrapper ID as soon as the user attempts to instantiate an analyzer
100    crate::set_wrapper_id();
101
102    let mut collector_ptr: *mut AicCollector = ptr::null_mut();
103    let mut analyzer_ptr: *mut AicAnalyzer = ptr::null_mut();
104    let c_license_key = CString::new(license_key).map_err(|_| AicError::LicenseFormatInvalid)?;
105
106    // SAFETY:
107    // - `collector_ptr` and `analyzer_ptr` point to stack storage for output.
108    // - `model` is a valid SDK model pointer for the duration of the call.
109    // - `c_license_key` is a null-terminated CString.
110    // - This function is not thread-safe, but the output pointers are local to
111    //   this call and neither handle exists until it returns.
112    let error_code = unsafe {
113        aic_analyzer_pair_create(
114            &mut collector_ptr,
115            &mut analyzer_ptr,
116            model.as_const_ptr(),
117            c_license_key.as_ptr(),
118        )
119    };
120
121    handle_error(error_code)?;
122
123    assert!(
124        !collector_ptr.is_null(),
125        "C library returned success but null collector pointer"
126    );
127    assert!(
128        !analyzer_ptr.is_null(),
129        "C library returned success but null analyzer pointer"
130    );
131
132    let collector = Collector::new(collector_ptr);
133    let analyzer = Analyzer::new(analyzer_ptr, model);
134
135    Ok((collector, analyzer))
136}
137
138/// Buffers audio for later analysis.
139///
140/// The collector is designed to be placed in the audio thread,
141/// buffering audio chunks for the [`Analyzer`] to analyze later.
142pub struct Collector {
143    /// Raw pointer to the C collector structure.
144    inner: *mut AicCollector,
145    /// Configured number of channels.
146    num_channels: Option<u16>,
147}
148
149impl Collector {
150    fn new(collector_ptr: *mut AicCollector) -> Self {
151        Self {
152            inner: collector_ptr,
153            num_channels: None,
154        }
155    }
156
157    /// Configures the collector for specific audio settings.
158    ///
159    /// This function must be called before buffering any audio.
160    /// For the lowest delay use the sample rate and frame size returned by
161    /// [`Model::optimal_sample_rate`] and [`Model::optimal_num_frames`].
162    ///
163    /// # Arguments
164    ///
165    /// * `config` - Audio buffering configuration
166    ///
167    /// # Returns
168    ///
169    /// Returns `Ok(())` on success or an `AicError` if initialization fails.
170    ///
171    /// # Warning
172    /// Do not call from audio processing threads as this allocates memory.
173    ///
174    /// # Note
175    /// All channels are mixed to mono for buffering. To buffer channels
176    /// independently, create separate [`Collector`] instances.
177    ///
178    /// # Example
179    ///
180    /// ```rust,no_run
181    /// # use aic_sdk::{Model, ProcessorConfig};
182    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
183    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
184    /// # let (mut collector, _) = aic_sdk::analyzer_pair(&model, &license_key)?;
185    /// let config = ProcessorConfig::optimal(&model);
186    /// collector.initialize(&config)?;
187    /// # Ok::<(), aic_sdk::AicError>(())
188    /// ```
189    pub fn initialize(&mut self, config: &ProcessorConfig) -> Result<(), AicError> {
190        // SAFETY:
191        // - `self.inner` is a valid pointer to a live collector.
192        // - This function is not thread-safe, so we borrow `&mut self`.
193        let error_code = unsafe {
194            aic_collector_initialize(
195                self.inner,
196                config.sample_rate,
197                config.num_channels,
198                config.num_frames,
199                config.allow_variable_frames,
200            )
201        };
202
203        handle_error(error_code)?;
204        self.num_channels = Some(config.num_channels);
205        Ok(())
206    }
207
208    /// Buffers audio with separate buffers for each channel (planar layout).
209    ///
210    /// **Memory Layout:**
211    /// - Separate buffer for each channel
212    /// - Each buffer contains `num_frames` floats
213    /// - Maximum of 16 channels supported
214    /// - Example for 2 channels, 4 frames:
215    ///   ```text
216    ///   audio[0] -> [ch0_f0, ch0_f1, ch0_f2, ch0_f3]
217    ///   audio[1] -> [ch1_f0, ch1_f1, ch1_f2, ch1_f3]
218    ///   ```
219    ///
220    /// The function accepts any type of collection of `f32` values that implements `as_mut`, e.g.:
221    /// - `[vec![0.0; 128]; 2]`
222    /// - `[[0.0; 128]; 2]`
223    /// - `[&mut ch1, &mut ch2]`
224    ///
225    /// # Arguments
226    ///
227    /// * `audio` - Array of mutable channel buffer slices to be buffered.
228    ///             Each channel buffer must be exactly of size `num_frames`,
229    ///             or if `allow_variable_frames` was enabled, less than the initialization value.
230    ///
231    /// # Notes
232    ///
233    /// - All channels are mixed to mono for buffering. To buffer channels
234    ///   independently, create separate [`Collector`] instances.
235    /// - Maximum supported number of channels is 16. Exceeding this will return an error.
236    ///
237    /// # Returns
238    ///
239    /// Returns `Ok(())` on success or an [`AicError`] if buffering fails.
240    ///
241    /// # Example
242    ///
243    /// ```rust,no_run
244    /// # use aic_sdk::{Model, ProcessorConfig};
245    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
246    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
247    /// # let (mut collector, _) = aic_sdk::analyzer_pair(&model, &license_key)?;
248    /// let config = ProcessorConfig::optimal(&model).with_num_channels(2);
249    /// collector.initialize(&config)?;
250    /// let audio = vec![vec![0.0f32; config.num_frames]; config.num_channels as usize];
251    /// collector.buffer_planar(&audio)?;
252    /// # Ok::<(), aic_sdk::AicError>(())
253    /// ```
254    #[allow(clippy::doc_overindented_list_items)]
255    pub fn buffer_planar<V: AsRef<[f32]>>(&mut self, audio: &[V]) -> Result<(), AicError> {
256        const MAX_CHANNELS: u16 = 16;
257
258        let Some(num_channels) = self.num_channels else {
259            return Err(AicError::ProcessorNotInitialized);
260        };
261
262        if audio.len() != num_channels as usize {
263            return Err(AicError::AudioConfigMismatch);
264        }
265
266        if num_channels > MAX_CHANNELS {
267            return Err(AicError::AudioConfigUnsupported);
268        }
269
270        let num_frames = if audio.is_empty() {
271            0
272        } else {
273            audio[0].as_ref().len()
274        };
275
276        let mut audio_ptrs = [std::ptr::null::<f32>(); MAX_CHANNELS as usize];
277        for (i, channel) in audio.iter().enumerate() {
278            if channel.as_ref().len() != num_frames {
279                return Err(AicError::AudioConfigMismatch);
280            }
281            audio_ptrs[i] = channel.as_ref().as_ptr();
282        }
283
284        // SAFETY:
285        // - `self.inner` is a valid pointer to a live collector.
286        // - `audio_ptrs` holds `num_channels` valid readable pointers with `num_frames` samples each.
287        // - This function is not thread-safe, so we borrow `&mut self`.
288        let error_code = unsafe {
289            aic_collector_buffer_planar(self.inner, audio_ptrs.as_ptr(), num_channels, num_frames)
290        };
291
292        handle_error(error_code)
293    }
294
295    /// Buffers audio with interleaved channel data.
296    ///
297    /// **Memory Layout:**
298    /// - Single contiguous buffer with samples alternating between channels
299    /// - Buffer size: `num_channels` * `num_frames` floats
300    /// - Example for 2 channels, 4 frames:
301    ///   ```text
302    ///   audio -> [ch0_f0, ch1_f0, ch0_f1, ch1_f1, ch0_f2, ch1_f2, ch0_f3, ch1_f3]
303    ///   ```
304    ///
305    /// # Arguments
306    ///
307    /// * `audio` - Interleaved audio buffer to be buffered.
308    ///             Must be exactly of size `num_channels` * `num_frames`,
309    ///             or if `allow_variable_frames` was enabled, less than the initialization value per channel.
310    ///
311    /// # Note
312    ///
313    /// All channels are mixed to mono for buffering. To buffer channels
314    /// independently, create separate [`Collector`] instances.
315    ///
316    /// # Returns
317    ///
318    /// Returns `Ok(())` on success or an [`AicError`] if buffering fails.
319    ///
320    /// # Example
321    ///
322    /// ```rust,no_run
323    /// # use aic_sdk::{Model, ProcessorConfig};
324    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
325    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
326    /// # let (mut collector, _) = aic_sdk::analyzer_pair(&model, &license_key)?;
327    /// let config = ProcessorConfig::optimal(&model).with_num_channels(2);
328    /// collector.initialize(&config)?;
329    /// let audio = vec![0.0f32; config.num_channels as usize * config.num_frames];
330    /// collector.buffer_interleaved(&audio)?;
331    /// # Ok::<(), aic_sdk::AicError>(())
332    /// ```
333    #[allow(clippy::doc_overindented_list_items)]
334    pub fn buffer_interleaved(&mut self, audio: &[f32]) -> Result<(), AicError> {
335        let Some(num_channels) = self.num_channels else {
336            return Err(AicError::ProcessorNotInitialized);
337        };
338
339        if !audio.len().is_multiple_of(num_channels as usize) {
340            return Err(AicError::AudioConfigMismatch);
341        }
342
343        let num_frames = audio.len() / num_channels as usize;
344
345        // SAFETY:
346        // - `self.inner` is a valid pointer to a live collector.
347        // - `audio` points to a contiguous f32 slice of length `num_channels * num_frames`.
348        // - This function is not thread-safe, so we borrow `&mut self`.
349        let error_code = unsafe {
350            aic_collector_buffer_interleaved(self.inner, audio.as_ptr(), num_channels, num_frames)
351        };
352
353        handle_error(error_code)
354    }
355
356    /// Buffers audio with sequential channel data.
357    ///
358    /// **Memory Layout:**
359    /// - Single contiguous buffer with all samples for each channel stored sequentially
360    /// - Buffer size: `num_channels` * `num_frames` floats
361    /// - Example for 2 channels, 4 frames:
362    ///   ```text
363    ///   audio -> [ch0_f0, ch0_f1, ch0_f2, ch0_f3, ch1_f0, ch1_f1, ch1_f2, ch1_f3]
364    ///   ```
365    ///
366    /// # Arguments
367    ///
368    /// * `audio` - Sequential audio buffer to be buffered.
369    ///             Must be exactly of size `num_channels` * `num_frames`,
370    ///             or if `allow_variable_frames` was enabled, less than the initialization value per channel.
371    /// # Note
372    ///
373    /// All channels are mixed to mono for buffering. To buffer channels
374    /// independently, create separate [`Collector`] instances.
375    ///
376    /// # Returns
377    ///
378    /// Returns `Ok(())` on success or an [`AicError`] if buffering fails.
379    ///
380    /// # Example
381    ///
382    /// ```rust,no_run
383    /// # use aic_sdk::{Model, ProcessorConfig};
384    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
385    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
386    /// # let (mut collector, _) = aic_sdk::analyzer_pair(&model, &license_key)?;
387    /// let config = ProcessorConfig::optimal(&model).with_num_channels(2);
388    /// collector.initialize(&config)?;
389    /// let audio = vec![0.0f32; config.num_channels as usize * config.num_frames];
390    /// collector.buffer_sequential(&audio)?;
391    /// # Ok::<(), aic_sdk::AicError>(())
392    /// ```
393    #[allow(clippy::doc_overindented_list_items)]
394    pub fn buffer_sequential(&mut self, audio: &[f32]) -> Result<(), AicError> {
395        let Some(num_channels) = self.num_channels else {
396            return Err(AicError::ProcessorNotInitialized);
397        };
398
399        if !audio.len().is_multiple_of(num_channels as usize) {
400            return Err(AicError::AudioConfigMismatch);
401        }
402
403        let num_frames = audio.len() / num_channels as usize;
404
405        // SAFETY:
406        // - `self.inner` is a valid pointer to a live collector.
407        // - `audio` points to a contiguous f32 slice of length `num_channels * num_frames`.
408        // - This function is not thread-safe, so we borrow `&mut self`.
409        let error_code = unsafe {
410            aic_collector_buffer_sequential(self.inner, audio.as_ptr(), num_channels, num_frames)
411        };
412
413        handle_error(error_code)
414    }
415}
416
417impl Drop for Collector {
418    fn drop(&mut self) {
419        if !self.inner.is_null() {
420            // SAFETY:
421            // - `self.inner` was allocated by the SDK and is still owned by this wrapper.
422            // - This function is not thread-safe with concurrent collector use,
423            //   but `drop` has exclusive access to `self`.
424            unsafe { aic_collector_destroy(self.inner) };
425        }
426    }
427}
428
429// SAFETY: Everything in Collector is Send, with the exception of the inner raw pointer.
430// The Collector only uses the raw pointer according to the safety contracts of the
431// unsafe APIs that require the pointer, and the Collector does not expose access to the
432// raw pointer in any of its methods. Therefore, it safe to implement Send for Collector.
433unsafe impl Send for Collector {}
434
435// SAFETY: Collector does not expose any interior mutability, and all unsafe APIs that make use of
436// the inner raw pointer uphold the thread safety contracts required by the unsafe APIs.
437// Therefore, it is safe to implement Sync for Collector.
438unsafe impl Sync for Collector {}
439
440/// Runs an analysis model over the audio buffered by a [`Collector`].
441///
442/// The analyzer is designed to be run in a non-audio thread. Analysis models are computationally expensive
443/// and cannot run in the audio thread. The analyzer has access to the audio buffered by the
444/// collector, and it can access it safely across threads.
445pub struct Analyzer<'a> {
446    /// Raw pointer to the C analyzer structure.
447    inner: *mut AicAnalyzer,
448    /// Marker to tie the analyzer to the lifetime of the model's weights.
449    marker: PhantomData<&'a [u8]>,
450}
451
452impl<'a> Analyzer<'a> {
453    fn new(analyzer_ptr: *mut AicAnalyzer, _model: &Model<'a>) -> Self {
454        Self {
455            inner: analyzer_ptr,
456            marker: PhantomData,
457        }
458    }
459
460    fn as_const_ptr(&self) -> *const AicAnalyzer {
461        self.inner as *const AicAnalyzer
462    }
463
464    /// Clears all internal state and buffers.
465    ///
466    /// Call this when the audio stream is interrupted or when seeking
467    /// to prevent mispredictions from previous audio content.
468    ///
469    /// This operates on both the analyzer and its collector.
470    ///
471    /// The [`Collector`] stays initialized to the configured settings.
472    ///
473    /// # Returns
474    ///
475    /// Returns `Ok(())` on success or an [`AicError`] if the reset fails.
476    ///
477    /// # Real-time safety
478    ///
479    /// Real-time safe. Can be called from audio processing threads.
480    ///
481    /// # Example
482    ///
483    /// ```rust,no_run
484    /// # use aic_sdk::Model;
485    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
486    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
487    /// # let (_, mut analyzer) = aic_sdk::analyzer_pair(&model, &license_key)?;
488    /// analyzer.reset()?;
489    /// # Ok::<(), aic_sdk::AicError>(())
490    /// ```
491    pub fn reset(&self) -> Result<(), AicError> {
492        // SAFETY:
493        // - `self.as_const_ptr()` is a valid pointer to a live analyzer.
494        // - This function can be called from any thread, so we only borrow `&self`.
495        let error_code = unsafe { aic_analyzer_reset(self.as_const_ptr()) };
496        handle_error(error_code)
497    }
498
499    /// Analyze the buffered signal.
500    ///
501    /// The analyzer runs a forward-pass of the analysis model with a fixed length of audio,
502    /// determined by the model.
503    ///
504    /// If this function is called before the collector has buffered that length of audio,
505    /// the analyzer will run the analysis with silence (zeros) in the tail of the input.
506    ///
507    /// # Note
508    /// When buffering, all channels are mixed down to mono. To analyze channels
509    /// independently, create separate analyzer pairs.
510    ///
511    /// # Returns
512    ///
513    /// Returns an [`AnalysisResult`] if successful, otherwise an [`AicError`].
514    ///
515    /// # Real-time safety
516    ///
517    /// This function is not real-time safe. Avoid calling it from audio threads.
518    pub fn analyze_buffered(&mut self) -> Result<AnalysisResult, AicError> {
519        let mut result = AicAnalysisResult {
520            risk_score: 0.0,
521            speaker_reverb: 0.0,
522            speaker_loudness: 0.0,
523            interfering_speech: 0.0,
524            media_speech: 0.0,
525            noise: 0.0,
526            packet_loss: 0.0,
527        };
528
529        // SAFETY:
530        // - `self.inner` is a valid pointer to a live analyzer.
531        // - `result` points to stack storage for output.
532        // - This function is not thread-safe, so we borrow `&mut self`.
533        let error_code = unsafe { aic_analyzer_analyze_buffered(self.inner, &mut result) };
534        handle_error(error_code)?;
535
536        Ok(result.into())
537    }
538
539    /// Replaces the bearer token on the analyzer.
540    ///
541    /// Use this when your license key is a JWT and needs to be refreshed before it expires.
542    /// Audio processing continues uninterrupted, the context handle stays valid, and the new
543    /// token is used for all subsequent authentication against the ai-coustics backend.
544    ///
545    /// In-place updates are only supported when both the originally configured key and the
546    /// new token are JWTs. If either side is not, the call returns
547    /// [`AicError::TokenUpdateUnsupported`] and the existing token stays in use.
548    ///
549    /// # Arguments
550    ///
551    /// * `token` - The new JWT to install.
552    ///
553    /// # Returns
554    ///
555    /// Returns `Ok(())` on success or an `AicError` if the update fails.
556    ///
557    /// # Real-time safety
558    ///
559    /// This function is not real-time safe. It locks a mutex and allocates memory.
560    /// Avoid calling it from audio threads.
561    ///
562    /// # Example
563    ///
564    /// ```rust,no_run
565    /// # use aic_sdk::Model;
566    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
567    /// # let model = Model::from_file("/path/to/model.aicmodel")?;
568    /// # let (_, analyzer) = aic_sdk::analyzer_pair(&model, &license_key)?;
569    /// let renewed_jwt = String::from("<JWT_BEARER_TOKEN>");
570    /// analyzer.update_bearer_token(&renewed_jwt)?;
571    /// # Ok::<(), aic_sdk::AicError>(())
572    /// ```
573    pub fn update_bearer_token(&self, token: &str) -> Result<(), AicError> {
574        let c_token = CString::new(token).map_err(|_| AicError::LicenseFormatInvalid)?;
575
576        // SAFETY:
577        // - `self.as_const_ptr()` is a valid pointer to a live analyzer.
578        // - `c_token` is a null-terminated CString that outlives the call.
579        // - This function can run concurrently with collector buffering; Rust
580        //   prevents concurrent analyze or destroy on the same analyzer handle.
581        let error_code =
582            unsafe { aic_analyzer_update_bearer_token(self.as_const_ptr(), c_token.as_ptr()) };
583        handle_error(error_code)
584    }
585}
586
587impl<'a> Drop for Analyzer<'a> {
588    fn drop(&mut self) {
589        if !self.inner.is_null() {
590            // SAFETY:
591            // - `self.inner` was allocated by the SDK and is still owned by this wrapper.
592            // - This function is not thread-safe with concurrent analyzer use,
593            //   but `drop` has exclusive access to `self`.
594            unsafe { aic_analyzer_destroy(self.inner) };
595        }
596    }
597}
598
599// SAFETY: Everything in Analyzer is Send, with the exception of the inner raw pointer.
600// The Analyzer only uses the raw pointer according to the safety contracts of the
601// unsafe APIs that require the pointer, and the Analyzer does not expose access to the
602// raw pointer in any of its methods. Therefore, it safe to implement Send for Analyzer.
603unsafe impl<'a> Send for Analyzer<'a> {}
604
605// SAFETY: Analyzer does not expose any interior mutability, and all unsafe APIs that make use of
606// the inner raw pointer uphold the thread safety contracts required by the unsafe APIs.
607// Therefore, it is safe to implement Sync for Analyzer.
608unsafe impl<'a> Sync for Analyzer<'a> {}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use std::{
614        fs,
615        path::{Path, PathBuf},
616        sync::{Mutex, OnceLock},
617    };
618
619    fn download_lock() -> &'static Mutex<()> {
620        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
621        LOCK.get_or_init(|| Mutex::new(()))
622    }
623
624    fn find_existing_model(target_dir: &Path) -> Option<PathBuf> {
625        let entries = fs::read_dir(target_dir).ok()?;
626        for entry in entries.flatten() {
627            let path = entry.path();
628            if path
629                .file_name()
630                .and_then(|n| n.to_str())
631                .map(|name| name.contains("tyto_l_16khz") && name.ends_with(".aicmodel"))
632                .unwrap_or(false)
633                && path.is_file()
634            {
635                return Some(path);
636            }
637        }
638        None
639    }
640
641    /// Downloads the default test model `tyto-l-16khz` into the crate's `target/` directory.
642    /// Returns the path to the downloaded model file.
643    fn get_tyto_l_16khz() -> Result<PathBuf, AicError> {
644        let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
645
646        if let Some(existing) = find_existing_model(&target_dir) {
647            return Ok(existing);
648        }
649
650        let _guard = download_lock().lock().unwrap();
651        if let Some(existing) = find_existing_model(&target_dir) {
652            return Ok(existing);
653        }
654
655        if cfg!(feature = "download-model") {
656            Model::download("tyto-l-16khz", target_dir)
657        } else {
658            panic!(
659                "Model `tyto-l-16khz` not found in {} and `download-model` feature is disabled",
660                target_dir.display()
661            );
662        }
663    }
664
665    fn load_test_model() -> Result<(Model<'static>, String), AicError> {
666        let license_key = std::env::var("AIC_SDK_LICENSE")
667            .expect("AIC_SDK_LICENSE environment variable must be set for tests");
668
669        let model_path = get_tyto_l_16khz()?;
670        let model = Model::from_file(&model_path)?;
671
672        Ok((model, license_key))
673    }
674
675    fn test_analyzer_pair(
676        model: &Model<'static>,
677        license_key: &str,
678    ) -> (Collector, Analyzer<'static>) {
679        analyzer_pair(model, license_key)
680            .expect("tyto-l-16khz should create a collector/analyzer pair")
681    }
682
683    fn assert_score_range(result: &AnalysisResult) {
684        assert!((0.0..=1.0).contains(&result.risk_score));
685        assert!((0.0..=1.0).contains(&result.speaker_reverb));
686        assert!((0.0..=1.0).contains(&result.speaker_loudness));
687        assert!((0.0..=1.0).contains(&result.interfering_speech));
688        assert!((0.0..=1.0).contains(&result.media_speech));
689        assert!((0.0..=1.0).contains(&result.noise));
690        assert!((0.0..=1.0).contains(&result.packet_loss));
691    }
692
693    #[test]
694    fn analysis_result_maps_all_ffi_fields() {
695        let ffi_result = AicAnalysisResult {
696            risk_score: 0.1,
697            speaker_reverb: 0.2,
698            speaker_loudness: 0.3,
699            interfering_speech: 0.4,
700            media_speech: 0.5,
701            noise: 0.6,
702            packet_loss: 0.7,
703        };
704
705        assert_eq!(
706            AnalysisResult::from(ffi_result),
707            AnalysisResult {
708                risk_score: 0.1,
709                speaker_reverb: 0.2,
710                speaker_loudness: 0.3,
711                interfering_speech: 0.4,
712                media_speech: 0.5,
713                noise: 0.6,
714                packet_loss: 0.7,
715            }
716        );
717    }
718
719    #[test]
720    fn collector_rejects_buffering_before_initialize() {
721        let mut collector = Collector {
722            inner: ptr::null_mut(),
723            num_channels: None,
724        };
725
726        let planar = [vec![0.0f32; 4]];
727        let contiguous = vec![0.0f32; 4];
728
729        assert_eq!(
730            collector.buffer_planar(&planar),
731            Err(AicError::ProcessorNotInitialized)
732        );
733        assert_eq!(
734            collector.buffer_interleaved(&contiguous),
735            Err(AicError::ProcessorNotInitialized)
736        );
737        assert_eq!(
738            collector.buffer_sequential(&contiguous),
739            Err(AicError::ProcessorNotInitialized)
740        );
741    }
742
743    #[test]
744    fn collector_validates_planar_layout_before_ffi() {
745        let mut collector = Collector {
746            inner: ptr::null_mut(),
747            num_channels: Some(2),
748        };
749
750        let wrong_channel_count = [vec![0.0f32; 4]];
751        assert_eq!(
752            collector.buffer_planar(&wrong_channel_count),
753            Err(AicError::AudioConfigMismatch)
754        );
755
756        let mismatched_frames = [vec![0.0f32; 4], vec![0.0f32; 3]];
757        assert_eq!(
758            collector.buffer_planar(&mismatched_frames),
759            Err(AicError::AudioConfigMismatch)
760        );
761
762        collector.num_channels = Some(17);
763        let too_many_channels = vec![vec![0.0f32; 4]; 17];
764        assert_eq!(
765            collector.buffer_planar(&too_many_channels),
766            Err(AicError::AudioConfigUnsupported)
767        );
768    }
769
770    #[test]
771    fn collector_validates_contiguous_layout_before_ffi() {
772        let mut collector = Collector {
773            inner: ptr::null_mut(),
774            num_channels: Some(2),
775        };
776        let not_divisible_by_channels = vec![0.0f32; 3];
777
778        assert_eq!(
779            collector.buffer_interleaved(&not_divisible_by_channels),
780            Err(AicError::AudioConfigMismatch)
781        );
782        assert_eq!(
783            collector.buffer_sequential(&not_divisible_by_channels),
784            Err(AicError::AudioConfigMismatch)
785        );
786    }
787
788    #[test]
789    fn analyzer_pair_rejects_license_key_with_nul() {
790        let (model, _) = load_test_model().unwrap();
791
792        let result = analyzer_pair(&model, "invalid\0license");
793
794        assert!(matches!(result, Err(AicError::LicenseFormatInvalid)));
795    }
796
797    #[test]
798    fn collector_buffers_all_layouts_and_analyzer_returns_scores() {
799        let (model, license_key) = load_test_model().unwrap();
800        let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
801        let config = ProcessorConfig::optimal(&model).with_num_channels(2);
802        collector.initialize(&config).unwrap();
803
804        let mut left = vec![0.0f32; config.num_frames];
805        let mut right = vec![0.0f32; config.num_frames];
806        let planar = [left.as_mut_slice(), right.as_mut_slice()];
807        collector.buffer_planar(&planar).unwrap();
808
809        let num_channels = config.num_channels as usize;
810        let contiguous = vec![0.0f32; num_channels * config.num_frames];
811        collector.buffer_interleaved(&contiguous).unwrap();
812        collector.buffer_sequential(&contiguous).unwrap();
813
814        let result = analyzer.analyze_buffered().unwrap();
815        assert_score_range(&result);
816    }
817
818    #[test]
819    fn collector_buffers_variable_frames_when_enabled() {
820        let (model, license_key) = load_test_model().unwrap();
821        let (mut collector, _analyzer) = test_analyzer_pair(&model, &license_key);
822        let config = ProcessorConfig::optimal(&model)
823            .with_num_channels(2)
824            .with_allow_variable_frames(true);
825        collector.initialize(&config).unwrap();
826
827        let num_channels = config.num_channels as usize;
828        let full = vec![0.0f32; num_channels * config.num_frames];
829        collector.buffer_interleaved(&full).unwrap();
830        collector.buffer_sequential(&full).unwrap();
831
832        let short = vec![0.0f32; num_channels * 20];
833        collector.buffer_interleaved(&short).unwrap();
834        collector.buffer_sequential(&short).unwrap();
835
836        let left = vec![0.0f32; 20];
837        let right = vec![0.0f32; 20];
838        let planar = [left.as_slice(), right.as_slice()];
839        collector.buffer_planar(&planar).unwrap();
840    }
841
842    #[test]
843    fn collector_rejects_variable_frames_when_disabled() {
844        let (model, license_key) = load_test_model().unwrap();
845        let (mut collector, _analyzer) = test_analyzer_pair(&model, &license_key);
846        let config = ProcessorConfig::optimal(&model).with_num_channels(2);
847        collector.initialize(&config).unwrap();
848
849        let num_channels = config.num_channels as usize;
850        let full = vec![0.0f32; num_channels * config.num_frames];
851        collector.buffer_interleaved(&full).unwrap();
852        collector.buffer_sequential(&full).unwrap();
853
854        let short = vec![0.0f32; num_channels * 20];
855        assert_eq!(
856            collector.buffer_interleaved(&short),
857            Err(AicError::AudioConfigMismatch)
858        );
859        assert_eq!(
860            collector.buffer_sequential(&short),
861            Err(AicError::AudioConfigMismatch)
862        );
863
864        let left = vec![0.0f32; 20];
865        let right = vec![0.0f32; 20];
866        let planar = [left.as_slice(), right.as_slice()];
867        assert_eq!(
868            collector.buffer_planar(&planar),
869            Err(AicError::AudioConfigMismatch)
870        );
871    }
872
873    #[test]
874    fn analyzer_reset_keeps_collector_initialized() {
875        let (model, license_key) = load_test_model().unwrap();
876        let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
877        let config = ProcessorConfig::optimal(&model).with_num_channels(2);
878        collector.initialize(&config).unwrap();
879
880        analyzer.reset().unwrap();
881
882        let num_channels = config.num_channels as usize;
883        let audio = vec![0.0f32; num_channels * config.num_frames];
884        collector.buffer_interleaved(&audio).unwrap();
885
886        let result = analyzer.analyze_buffered().unwrap();
887        assert_score_range(&result);
888    }
889
890    #[test]
891    fn model_can_be_dropped_after_creating_analyzer_pair() {
892        let (model, license_key) = load_test_model().unwrap();
893        let config = ProcessorConfig::optimal(&model).with_num_channels(2);
894        let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
895        drop(model); // The SDK keeps the model data alive for analyzer instances created from files.
896
897        collector.initialize(&config).unwrap();
898
899        let num_channels = config.num_channels as usize;
900        let audio = vec![0.0f32; num_channels * config.num_frames];
901        collector.buffer_interleaved(&audio).unwrap();
902
903        let result = analyzer.analyze_buffered().unwrap();
904        assert_score_range(&result);
905    }
906
907    #[test]
908    fn collector_and_analyzer_are_send_and_sync() {
909        // Compile-time check that Collector and Analyzer can cross thread boundaries.
910        fn assert_send<T: Send>() {}
911        fn assert_sync<T: Send>() {}
912
913        assert_send::<Collector>();
914        assert_sync::<Collector>();
915        assert_send::<Analyzer>();
916        assert_sync::<Analyzer>();
917    }
918}
919
920#[doc(hidden)]
921mod _compile_fail_tests {
922    //! Compile-fail regression: an `Analyzer`'s model buffer must not be dropped before the analyzer.
923    //!
924    //! ```rust,compile_fail
925    //! use aic_sdk::{Model, ProcessorConfig, analyzer_pair};
926    //!
927    //! fn main() {
928    //!     let buffer = vec![0u8; 64];
929    //!     let model = Model::from_buffer(&buffer).unwrap();
930    //!     let config = ProcessorConfig::optimal(&model).with_num_channels(2);
931    //!
932    //!     let (mut collector, mut analyzer) = analyzer_pair(&model, "license").unwrap();
933    //!     collector.initialize(&config).unwrap();
934    //!
935    //!     drop(model); // Model can be dropped without issues
936    //!
937    //!     drop(buffer); // This should fail to compile
938    //!
939    //!     let audio = vec![0.0f32; config.num_channels as usize * config.num_frames];
940    //!     collector.buffer_interleaved(&audio).unwrap();
941    //!     analyzer.analyze_buffered().unwrap();
942    //! }
943    //! ```
944}