use crate::{error::*, model::Model, processor::ProcessorConfig};
use aic_sdk_sys::*;
use std::{ffi::CString, marker::PhantomData, ptr};
#[derive(Debug, Clone, PartialEq)]
pub struct AnalysisResult {
pub risk_score: f32,
pub speaker_reverb: f32,
pub speaker_loudness: f32,
pub interfering_speech: f32,
pub media_speech: f32,
pub noise: f32,
pub packet_loss: f32,
}
impl From<AicAnalysisResult> for AnalysisResult {
fn from(value: AicAnalysisResult) -> Self {
Self {
risk_score: value.risk_score,
speaker_reverb: value.speaker_reverb,
speaker_loudness: value.speaker_loudness,
interfering_speech: value.interfering_speech,
media_speech: value.media_speech,
noise: value.noise,
packet_loss: value.packet_loss,
}
}
}
pub fn analyzer_pair<'a>(
model: &Model<'a>,
license_key: &str,
) -> Result<(Collector, Analyzer<'a>), AicError> {
crate::set_wrapper_id();
let mut collector_ptr: *mut AicCollector = ptr::null_mut();
let mut analyzer_ptr: *mut AicAnalyzer = ptr::null_mut();
let c_license_key = CString::new(license_key).map_err(|_| AicError::LicenseFormatInvalid)?;
let error_code = unsafe {
aic_analyzer_pair_create(
&mut collector_ptr,
&mut analyzer_ptr,
model.as_const_ptr(),
c_license_key.as_ptr(),
)
};
handle_error(error_code)?;
assert!(
!collector_ptr.is_null(),
"C library returned success but null collector pointer"
);
assert!(
!analyzer_ptr.is_null(),
"C library returned success but null analyzer pointer"
);
let collector = Collector::new(collector_ptr);
let analyzer = Analyzer::new(analyzer_ptr, model);
Ok((collector, analyzer))
}
pub struct Collector {
inner: *mut AicCollector,
num_channels: Option<u16>,
}
impl Collector {
fn new(collector_ptr: *mut AicCollector) -> Self {
Self {
inner: collector_ptr,
num_channels: None,
}
}
pub fn initialize(&mut self, config: &ProcessorConfig) -> Result<(), AicError> {
let error_code = unsafe {
aic_collector_initialize(
self.inner,
config.sample_rate,
config.num_channels,
config.num_frames,
config.allow_variable_frames,
)
};
handle_error(error_code)?;
self.num_channels = Some(config.num_channels);
Ok(())
}
#[allow(clippy::doc_overindented_list_items)]
pub fn buffer_planar<V: AsRef<[f32]>>(&mut self, audio: &[V]) -> Result<(), AicError> {
const MAX_CHANNELS: u16 = 16;
let Some(num_channels) = self.num_channels else {
return Err(AicError::ProcessorNotInitialized);
};
if audio.len() != num_channels as usize {
return Err(AicError::AudioConfigMismatch);
}
if num_channels > MAX_CHANNELS {
return Err(AicError::AudioConfigUnsupported);
}
let num_frames = if audio.is_empty() {
0
} else {
audio[0].as_ref().len()
};
let mut audio_ptrs = [std::ptr::null::<f32>(); MAX_CHANNELS as usize];
for (i, channel) in audio.iter().enumerate() {
if channel.as_ref().len() != num_frames {
return Err(AicError::AudioConfigMismatch);
}
audio_ptrs[i] = channel.as_ref().as_ptr();
}
let error_code = unsafe {
aic_collector_buffer_planar(self.inner, audio_ptrs.as_ptr(), num_channels, num_frames)
};
handle_error(error_code)
}
#[allow(clippy::doc_overindented_list_items)]
pub fn buffer_interleaved(&mut self, audio: &[f32]) -> Result<(), AicError> {
let Some(num_channels) = self.num_channels else {
return Err(AicError::ProcessorNotInitialized);
};
if !audio.len().is_multiple_of(num_channels as usize) {
return Err(AicError::AudioConfigMismatch);
}
let num_frames = audio.len() / num_channels as usize;
let error_code = unsafe {
aic_collector_buffer_interleaved(self.inner, audio.as_ptr(), num_channels, num_frames)
};
handle_error(error_code)
}
#[allow(clippy::doc_overindented_list_items)]
pub fn buffer_sequential(&mut self, audio: &[f32]) -> Result<(), AicError> {
let Some(num_channels) = self.num_channels else {
return Err(AicError::ProcessorNotInitialized);
};
if !audio.len().is_multiple_of(num_channels as usize) {
return Err(AicError::AudioConfigMismatch);
}
let num_frames = audio.len() / num_channels as usize;
let error_code = unsafe {
aic_collector_buffer_sequential(self.inner, audio.as_ptr(), num_channels, num_frames)
};
handle_error(error_code)
}
}
impl Drop for Collector {
fn drop(&mut self) {
if !self.inner.is_null() {
unsafe { aic_collector_destroy(self.inner) };
}
}
}
unsafe impl Send for Collector {}
unsafe impl Sync for Collector {}
pub struct Analyzer<'a> {
inner: *mut AicAnalyzer,
marker: PhantomData<&'a [u8]>,
}
impl<'a> Analyzer<'a> {
fn new(analyzer_ptr: *mut AicAnalyzer, _model: &Model<'a>) -> Self {
Self {
inner: analyzer_ptr,
marker: PhantomData,
}
}
fn as_const_ptr(&self) -> *const AicAnalyzer {
self.inner as *const AicAnalyzer
}
pub fn reset(&self) -> Result<(), AicError> {
let error_code = unsafe { aic_analyzer_reset(self.as_const_ptr()) };
handle_error(error_code)
}
pub fn analyze_buffered(&mut self) -> Result<AnalysisResult, AicError> {
let mut result = AicAnalysisResult {
risk_score: 0.0,
speaker_reverb: 0.0,
speaker_loudness: 0.0,
interfering_speech: 0.0,
media_speech: 0.0,
noise: 0.0,
packet_loss: 0.0,
};
let error_code = unsafe { aic_analyzer_analyze_buffered(self.inner, &mut result) };
handle_error(error_code)?;
Ok(result.into())
}
pub fn update_bearer_token(&self, token: &str) -> Result<(), AicError> {
let c_token = CString::new(token).map_err(|_| AicError::LicenseFormatInvalid)?;
let error_code =
unsafe { aic_analyzer_update_bearer_token(self.as_const_ptr(), c_token.as_ptr()) };
handle_error(error_code)
}
}
impl<'a> Drop for Analyzer<'a> {
fn drop(&mut self) {
if !self.inner.is_null() {
unsafe { aic_analyzer_destroy(self.inner) };
}
}
}
unsafe impl<'a> Send for Analyzer<'a> {}
unsafe impl<'a> Sync for Analyzer<'a> {}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
fn download_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn find_existing_model(target_dir: &Path) -> Option<PathBuf> {
let entries = fs::read_dir(target_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path
.file_name()
.and_then(|n| n.to_str())
.map(|name| name.contains("tyto_l_16khz") && name.ends_with(".aicmodel"))
.unwrap_or(false)
&& path.is_file()
{
return Some(path);
}
}
None
}
fn get_tyto_l_16khz() -> Result<PathBuf, AicError> {
let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
if let Some(existing) = find_existing_model(&target_dir) {
return Ok(existing);
}
let _guard = download_lock().lock().unwrap();
if let Some(existing) = find_existing_model(&target_dir) {
return Ok(existing);
}
if cfg!(feature = "download-model") {
Model::download("tyto-l-16khz", target_dir)
} else {
panic!(
"Model `tyto-l-16khz` not found in {} and `download-model` feature is disabled",
target_dir.display()
);
}
}
fn load_test_model() -> Result<(Model<'static>, String), AicError> {
let license_key = std::env::var("AIC_SDK_LICENSE")
.expect("AIC_SDK_LICENSE environment variable must be set for tests");
let model_path = get_tyto_l_16khz()?;
let model = Model::from_file(&model_path)?;
Ok((model, license_key))
}
fn test_analyzer_pair(
model: &Model<'static>,
license_key: &str,
) -> (Collector, Analyzer<'static>) {
analyzer_pair(model, license_key)
.expect("tyto-l-16khz should create a collector/analyzer pair")
}
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.media_speech));
assert!((0.0..=1.0).contains(&result.noise));
assert!((0.0..=1.0).contains(&result.packet_loss));
}
#[test]
fn analysis_result_maps_all_ffi_fields() {
let ffi_result = AicAnalysisResult {
risk_score: 0.1,
speaker_reverb: 0.2,
speaker_loudness: 0.3,
interfering_speech: 0.4,
media_speech: 0.5,
noise: 0.6,
packet_loss: 0.7,
};
assert_eq!(
AnalysisResult::from(ffi_result),
AnalysisResult {
risk_score: 0.1,
speaker_reverb: 0.2,
speaker_loudness: 0.3,
interfering_speech: 0.4,
media_speech: 0.5,
noise: 0.6,
packet_loss: 0.7,
}
);
}
#[test]
fn collector_rejects_buffering_before_initialize() {
let mut collector = Collector {
inner: ptr::null_mut(),
num_channels: None,
};
let planar = [vec![0.0f32; 4]];
let contiguous = vec![0.0f32; 4];
assert_eq!(
collector.buffer_planar(&planar),
Err(AicError::ProcessorNotInitialized)
);
assert_eq!(
collector.buffer_interleaved(&contiguous),
Err(AicError::ProcessorNotInitialized)
);
assert_eq!(
collector.buffer_sequential(&contiguous),
Err(AicError::ProcessorNotInitialized)
);
}
#[test]
fn collector_validates_planar_layout_before_ffi() {
let mut collector = Collector {
inner: ptr::null_mut(),
num_channels: Some(2),
};
let wrong_channel_count = [vec![0.0f32; 4]];
assert_eq!(
collector.buffer_planar(&wrong_channel_count),
Err(AicError::AudioConfigMismatch)
);
let mismatched_frames = [vec![0.0f32; 4], vec![0.0f32; 3]];
assert_eq!(
collector.buffer_planar(&mismatched_frames),
Err(AicError::AudioConfigMismatch)
);
collector.num_channels = Some(17);
let too_many_channels = vec![vec![0.0f32; 4]; 17];
assert_eq!(
collector.buffer_planar(&too_many_channels),
Err(AicError::AudioConfigUnsupported)
);
}
#[test]
fn collector_validates_contiguous_layout_before_ffi() {
let mut collector = Collector {
inner: ptr::null_mut(),
num_channels: Some(2),
};
let not_divisible_by_channels = vec![0.0f32; 3];
assert_eq!(
collector.buffer_interleaved(¬_divisible_by_channels),
Err(AicError::AudioConfigMismatch)
);
assert_eq!(
collector.buffer_sequential(¬_divisible_by_channels),
Err(AicError::AudioConfigMismatch)
);
}
#[test]
fn analyzer_pair_rejects_license_key_with_nul() {
let (model, _) = load_test_model().unwrap();
let result = analyzer_pair(&model, "invalid\0license");
assert!(matches!(result, Err(AicError::LicenseFormatInvalid)));
}
#[test]
fn collector_buffers_all_layouts_and_analyzer_returns_scores() {
let (model, license_key) = load_test_model().unwrap();
let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
let config = ProcessorConfig::optimal(&model).with_num_channels(2);
collector.initialize(&config).unwrap();
let mut left = vec![0.0f32; config.num_frames];
let mut right = vec![0.0f32; config.num_frames];
let planar = [left.as_mut_slice(), right.as_mut_slice()];
collector.buffer_planar(&planar).unwrap();
let num_channels = config.num_channels as usize;
let contiguous = vec![0.0f32; num_channels * config.num_frames];
collector.buffer_interleaved(&contiguous).unwrap();
collector.buffer_sequential(&contiguous).unwrap();
let result = analyzer.analyze_buffered().unwrap();
assert_score_range(&result);
}
#[test]
fn collector_buffers_variable_frames_when_enabled() {
let (model, license_key) = load_test_model().unwrap();
let (mut collector, _analyzer) = test_analyzer_pair(&model, &license_key);
let config = ProcessorConfig::optimal(&model)
.with_num_channels(2)
.with_allow_variable_frames(true);
collector.initialize(&config).unwrap();
let num_channels = config.num_channels as usize;
let full = vec![0.0f32; num_channels * config.num_frames];
collector.buffer_interleaved(&full).unwrap();
collector.buffer_sequential(&full).unwrap();
let short = vec![0.0f32; num_channels * 20];
collector.buffer_interleaved(&short).unwrap();
collector.buffer_sequential(&short).unwrap();
let left = vec![0.0f32; 20];
let right = vec![0.0f32; 20];
let planar = [left.as_slice(), right.as_slice()];
collector.buffer_planar(&planar).unwrap();
}
#[test]
fn collector_rejects_variable_frames_when_disabled() {
let (model, license_key) = load_test_model().unwrap();
let (mut collector, _analyzer) = test_analyzer_pair(&model, &license_key);
let config = ProcessorConfig::optimal(&model).with_num_channels(2);
collector.initialize(&config).unwrap();
let num_channels = config.num_channels as usize;
let full = vec![0.0f32; num_channels * config.num_frames];
collector.buffer_interleaved(&full).unwrap();
collector.buffer_sequential(&full).unwrap();
let short = vec![0.0f32; num_channels * 20];
assert_eq!(
collector.buffer_interleaved(&short),
Err(AicError::AudioConfigMismatch)
);
assert_eq!(
collector.buffer_sequential(&short),
Err(AicError::AudioConfigMismatch)
);
let left = vec![0.0f32; 20];
let right = vec![0.0f32; 20];
let planar = [left.as_slice(), right.as_slice()];
assert_eq!(
collector.buffer_planar(&planar),
Err(AicError::AudioConfigMismatch)
);
}
#[test]
fn analyzer_reset_keeps_collector_initialized() {
let (model, license_key) = load_test_model().unwrap();
let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
let config = ProcessorConfig::optimal(&model).with_num_channels(2);
collector.initialize(&config).unwrap();
analyzer.reset().unwrap();
let num_channels = config.num_channels as usize;
let audio = vec![0.0f32; num_channels * config.num_frames];
collector.buffer_interleaved(&audio).unwrap();
let result = analyzer.analyze_buffered().unwrap();
assert_score_range(&result);
}
#[test]
fn model_can_be_dropped_after_creating_analyzer_pair() {
let (model, license_key) = load_test_model().unwrap();
let config = ProcessorConfig::optimal(&model).with_num_channels(2);
let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
drop(model);
collector.initialize(&config).unwrap();
let num_channels = config.num_channels as usize;
let audio = vec![0.0f32; num_channels * config.num_frames];
collector.buffer_interleaved(&audio).unwrap();
let result = analyzer.analyze_buffered().unwrap();
assert_score_range(&result);
}
#[test]
fn collector_and_analyzer_are_send_and_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Send>() {}
assert_send::<Collector>();
assert_sync::<Collector>();
assert_send::<Analyzer>();
assert_sync::<Analyzer>();
}
}
#[doc(hidden)]
mod _compile_fail_tests {
}