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> {
unsafe { crate::set_sdk_id(2) };
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,
initialized: bool,
}
impl Collector {
fn new(collector_ptr: *mut AicCollector) -> Self {
Self {
inner: collector_ptr,
initialized: false,
}
}
pub fn initialize(&mut self, config: &ProcessorConfig) -> Result<(), AicError> {
let error_code = unsafe {
aic_collector_initialize(
self.inner,
config.sample_rate,
config.block_size,
config.variable_block_size,
)
};
handle_error(error_code)?;
self.initialized = true;
Ok(())
}
pub fn buffer(&mut self, audio: &[f32]) -> Result<(), AicError> {
if !self.initialized {
return Err(AicError::NotInitialized);
}
let audio_len = audio.len();
let error_code = unsafe { aic_collector_buffer(self.inner, audio.as_ptr(), audio_len) };
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 terminate_session(&mut self) -> Result<(), AicError> {
let error_code = unsafe { aic_analyzer_terminate_session(self.inner) };
handle_error(error_code)
}
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(),
initialized: false,
};
let audio = vec![0.0f32; 4];
assert_eq!(collector.buffer(&audio), Err(AicError::NotInitialized));
}
#[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_audio_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);
collector.initialize(&config).unwrap();
let audio = vec![0.0f32; config.block_size];
collector.buffer(&audio).unwrap();
let result = analyzer.analyze_buffered().unwrap();
assert_score_range(&result);
}
#[test]
fn collector_buffers_variable_block_size_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_variable_block_size(true);
collector.initialize(&config).unwrap();
let full = vec![0.0f32; config.block_size];
collector.buffer(&full).unwrap();
let short = vec![0.0f32; 20];
collector.buffer(&short).unwrap();
}
#[test]
fn collector_rejects_variable_block_size_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);
collector.initialize(&config).unwrap();
let full = vec![0.0f32; config.block_size];
collector.buffer(&full).unwrap();
let short = vec![0.0f32; 20];
assert_eq!(collector.buffer(&short), 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);
collector.initialize(&config).unwrap();
analyzer.reset().unwrap();
let audio = vec![0.0f32; config.block_size];
collector.buffer(&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);
let (mut collector, mut analyzer) = test_analyzer_pair(&model, &license_key);
drop(model);
collector.initialize(&config).unwrap();
let audio = vec![0.0f32; config.block_size];
collector.buffer(&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 {
}