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(¬_divisible_by_channels),
780 Err(AicError::AudioConfigMismatch)
781 );
782 assert_eq!(
783 collector.buffer_sequential(¬_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}