aic-sdk 0.25.0

ai-coustics SDK
Documentation
//! Energy-based voice activity detection using an enhancement processor.

use crate::{VadParameter, error::*};

use aic_sdk_sys::*;

/// Thread-safe control handle for a [`Processor`](crate::Processor)'s energy VAD.
///
/// Create one with [`Processor::energy_vad_context`](crate::Processor::energy_vad_context).
/// Every method on this type maps to an SDK function that can be called from any thread,
/// so a context can be moved to another thread to read the prediction, read and write
/// parameters, query the prediction delay, or reset the energy VAD while audio is being
/// processed elsewhere.
///
/// All handles created from a given processor reference the same energy VAD instance.
/// Detection uses the enhanced signal before output mixing, without running a separate model.
/// Creating a context keeps inference active even when the processor is bypassed or fully dry.
///
/// **Important:** If the backing [`Processor`](crate::Processor) is dropped, the energy VAD stops
/// producing new data. Dropping the context does not destroy the processor or disable detection.
///
/// # Example
///
/// ```rust,no_run
/// use aic_sdk::{Model, Processor};
///
/// let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
/// let model = Model::from_file("/path/to/enhancement_model.aicmodel")?;
/// let mut processor = Processor::new(&model, &license_key)?;
/// let vad_ctx = processor.energy_vad_context();
/// # Ok::<(), aic_sdk::AicError>(())
/// ```
pub struct EnergyVadContext {
    /// Raw pointer to the C energy VAD context structure
    inner: *mut AicEnergyVadContext,
}

impl EnergyVadContext {
    /// Creates a new energy VAD context.
    pub(crate) fn new(context_ptr: *mut AicEnergyVadContext) -> Self {
        Self { inner: context_ptr }
    }

    fn as_ptr(&self) -> *const AicEnergyVadContext {
        self.inner as *const AicEnergyVadContext
    }

    /// Returns the energy VAD's prediction.
    ///
    /// # Latency
    ///
    /// The latency of the energy VAD prediction is equal to the backing processor's processing latency,
    /// reported by [`EnergyVadContext::prediction_delay`]. The prediction lags its input by that many
    /// samples.
    ///
    /// Align speech decisions to the input timeline using that delay.
    ///
    /// If the backing processor stops being processed, the energy VAD will not update its prediction.
    pub fn is_speech_detected(&self) -> bool {
        let mut value: bool = false;
        // SAFETY:
        // - `self.as_ptr()` is a valid pointer to a live energy VAD context.
        // - `value` points to stack storage for output.
        // - This function can be called from any thread, so we only borrow `&self`.
        let error_code =
            unsafe { aic_energy_vad_context_is_speech_detected(self.as_ptr(), &mut value) };

        // This should never fail
        assert!(handle_error(error_code).is_ok());
        value
    }

    /// Modifies an energy VAD parameter.
    ///
    /// Sensitivity uses the energy-based range, 1.0 to 15.0, documented in [`VadParameter`].
    ///
    /// All parameters can be changed during audio processing.
    /// This function can be called from any thread.
    ///
    /// # Arguments
    ///
    /// - `parameter` - Parameter to modify
    /// - `value` - New parameter value. See parameter documentation for ranges
    ///
    /// # Returns
    ///
    /// Returns `Ok(())` on success or an `AicError` if the parameter cannot be set.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use aic_sdk::{Model, Processor, VadParameter};
    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
    /// # let model = Model::from_file("/path/to/enhancement_model.aicmodel")?;
    /// # let mut processor = Processor::new(&model, &license_key)?;
    /// # let vad_ctx = processor.energy_vad_context();
    /// vad_ctx.set_parameter(VadParameter::SpeechHoldDuration, 0.08)?;
    /// vad_ctx.set_parameter(VadParameter::Sensitivity, 6.0)?;
    /// # Ok::<(), aic_sdk::AicError>(())
    /// ```
    pub fn set_parameter(&self, parameter: VadParameter, value: f32) -> Result<(), AicError> {
        // SAFETY:
        // - `self.as_ptr()` is a live energy VAD context pointer.
        // - This function can be called from any thread, so we only borrow `&self`.
        let error_code =
            unsafe { aic_energy_vad_context_set_parameter(self.as_ptr(), parameter.into(), value) };
        handle_error(error_code)
    }

    /// Retrieves the current value of an energy VAD parameter.
    ///
    /// This function can be called from any thread.
    ///
    /// # Arguments
    ///
    /// - `parameter` - Parameter to query
    ///
    /// # Returns
    ///
    /// Returns the current parameter value.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use aic_sdk::{Model, Processor, VadParameter};
    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
    /// # let model = Model::from_file("/path/to/enhancement_model.aicmodel")?;
    /// # let mut processor = Processor::new(&model, &license_key)?;
    /// # let vad_ctx = processor.energy_vad_context();
    /// let sensitivity = vad_ctx.parameter(VadParameter::Sensitivity);
    /// println!("Current sensitivity: {sensitivity}");
    /// # Ok::<(), aic_sdk::AicError>(())
    /// ```
    pub fn parameter(&self, parameter: VadParameter) -> f32 {
        let mut value: f32 = 0.0;
        // SAFETY:
        // - `self.as_ptr()` is a valid pointer to a live energy VAD context.
        // - `value` points to stack storage for output.
        // - This function can be called from any thread, so we only borrow `&self`.
        let error_code = unsafe {
            aic_energy_vad_context_get_parameter(self.as_ptr(), parameter.into(), &mut value)
        };
        // The wrapper guarantees valid, non-null pointers.
        assert_success(
            error_code,
            "`aic_energy_vad_context_get_parameter` failed. This is a bug, please open an issue on GitHub for further investigation.",
        );
        value
    }

    /// Returns the total energy VAD prediction delay in samples for the current audio configuration.
    ///
    /// This function provides the complete end-to-end latency of the VAD prediction, which
    /// includes input reblocking, STFT, and model processing delay. Use this value to line up
    /// VAD decisions with the input timeline.
    ///
    /// The energy VAD uses the enhanced signal before output mixing. This delay is the same
    /// as the backing processor's [`ProcessorContext::audio_delay`](crate::ProcessorContext::audio_delay).
    /// Energy detection does not add audio delay. Speech hold and minimum speech duration
    /// settings additionally affect decision timing and are not included in this value.
    ///
    /// **Delay behavior:**
    /// - **Before initialization:** Returns the base processing delay using the model's
    ///   optimal block size at its native sample rate
    /// - **After initialization:** Returns the end-to-end VAD prediction delay at the
    ///   initialized sample rate, including the input-buffering latency of the configured
    ///   block size
    ///
    /// **Important:** After initialization, the delay value is expressed in samples at the sample rate
    /// you configured during [`Processor::initialize`](crate::Processor::initialize). To convert to time units:
    /// `delay_ms = (delay_samples * 1000) / sample_rate`
    ///
    /// **Note:** Using a block size different from the optimal value returned by
    /// [`Model::optimal_block_size`](crate::Model::optimal_block_size), or enabling variable
    /// block sizes, can add input-buffering latency before a new VAD prediction is published.
    /// That latency is included in the reported delay.
    ///
    /// # Returns
    ///
    /// Returns the delay in samples.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use aic_sdk::{Model, Processor};
    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
    /// # let model = Model::from_file("/path/to/enhancement_model.aicmodel")?;
    /// # let mut processor = Processor::new(&model, &license_key)?;
    /// # let vad_ctx = processor.energy_vad_context();
    /// let delay = vad_ctx.prediction_delay();
    /// println!("VAD prediction delay: {delay} samples");
    /// # Ok::<(), aic_sdk::AicError>(())
    /// ```
    pub fn prediction_delay(&self) -> usize {
        let mut delay: usize = 0;
        // SAFETY:
        // - `self.as_ptr()` is a valid pointer to a live energy VAD context.
        // - `delay` points to stack storage for output.
        // - This function can be called from any thread, so we only borrow `&self`.
        let error_code =
            unsafe { aic_energy_vad_context_get_prediction_delay(self.as_ptr(), &mut delay) };

        // This should never fail. If it does, it's a bug in the SDK.
        // `aic_energy_vad_context_get_prediction_delay` is documented to always succeed if given
        // valid pointers.
        assert_success(
            error_code,
            "`aic_energy_vad_context_get_prediction_delay` failed. This is a bug, please open an issue on GitHub for further investigation.",
        );

        delay
    }

    /// Resets the energy VAD state, clearing the published speech detection value.
    ///
    /// Call this when the audio stream is interrupted or when seeking
    /// to prevent mispredictions from previous audio content.
    ///
    /// The configured settings are retained, and the backing processor is not reset.
    ///
    /// # Real-time safety
    ///
    /// Real-time safe. Can be called from audio processing threads.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use aic_sdk::{Model, Processor};
    /// # let license_key = std::env::var("AIC_SDK_LICENSE").unwrap();
    /// # let model = Model::from_file("/path/to/enhancement_model.aicmodel")?;
    /// # let mut processor = Processor::new(&model, &license_key)?;
    /// # let vad_ctx = processor.energy_vad_context();
    /// vad_ctx.reset();
    /// # Ok::<(), aic_sdk::AicError>(())
    /// ```
    pub fn reset(&self) {
        // SAFETY:
        // - `self.as_ptr()` is a valid pointer to a live energy VAD context.
        // - This function can be called from any thread, so we only borrow `&self`.
        let error_code = unsafe { aic_energy_vad_context_reset(self.as_ptr()) };
        // The wrapper guarantees valid, non-null pointers.
        assert_success(
            error_code,
            "`aic_energy_vad_context_reset` failed. This is a bug, please open an issue on GitHub for further investigation.",
        );
    }
}

impl Drop for EnergyVadContext {
    fn drop(&mut self) {
        if !self.inner.is_null() {
            // SAFETY:
            // - `self.inner` was allocated by the SDK and is still owned by this wrapper.
            // - This function can be called from any thread; `drop` has exclusive
            //   access to this energy VAD context handle.
            unsafe { aic_energy_vad_context_destroy(self.inner) };
        }
    }
}

// Safety: The underlying C library should be thread-safe for individual EnergyVadContext instances
unsafe impl Send for EnergyVadContext {}
unsafe impl Sync for EnergyVadContext {}

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

    fn model() -> Model<'static> {
        Model::from_file(test_model_path("quail-l-16khz")).unwrap()
    }

    #[test]
    fn shared_parameters_reset_and_context_lifetime() {
        let model = model();
        let config = ProcessorConfig::optimal(&model);
        let mut processor = Processor::new(&model, &license_key())
            .unwrap()
            .with_config(&config)
            .unwrap();
        let vad = processor.energy_vad_context();
        let other = processor.energy_vad_context();
        assert!(!vad.is_speech_detected());
        assert_eq!(vad.prediction_delay(), processor.context().audio_delay());
        for (parameter, value) in [
            (VadParameter::Sensitivity, 7.0),
            (VadParameter::SpeechHoldDuration, 0.08),
            (VadParameter::MinimumSpeechDuration, 0.02),
        ] {
            vad.set_parameter(parameter, value).unwrap();
            assert_eq!(other.parameter(parameter), value);
        }
        for value in [0.5, 16.0, f32::NAN] {
            assert_eq!(
                vad.set_parameter(VadParameter::Sensitivity, value),
                Err(AicError::ParameterOutOfRange)
            );
        }
        processor
            .process(&mut vec![0.0; config.block_size])
            .unwrap();
        assert!(!vad.is_speech_detected());
        drop(other);
        drop(processor);
        drop(model);
        // A context owns shared state and remains usable after its processor is dropped.
        vad.reset();
        assert!(!vad.is_speech_detected());
        assert_eq!(vad.parameter(VadParameter::Sensitivity), 7.0);
    }

    #[test]
    fn detects_speech_while_bypassed_and_supports_cross_thread_control() {
        let model = model();
        let audio = audio_file::read::<f32>(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/test_signal.wav"),
            audio_file::ReadConfig {
                num_channels: Some(1),
                ..Default::default()
            },
        )
        .unwrap();
        let config = ProcessorConfig {
            sample_rate: audio.sample_rate,
            block_size: model.optimal_block_size(audio.sample_rate),
            variable_block_size: false,
        };
        let mut processor = Processor::new(&model, &license_key())
            .unwrap()
            .with_config(&config)
            .unwrap();
        let vad = processor.energy_vad_context();
        let control = processor.energy_vad_context();
        processor
            .context()
            .set_parameter(ProcessorParameter::Bypass, 1.0)
            .unwrap();
        std::thread::scope(|scope| {
            let worker = scope.spawn(move || {
                let mut heard_speech = false;
                let mut samples = audio.samples_interleaved;
                for block in samples.chunks_exact_mut(config.block_size) {
                    processor.process(block).unwrap();
                    heard_speech |= vad.is_speech_detected();
                }
                assert!(heard_speech);
                vad.reset();
                assert!(!vad.is_speech_detected());
            });
            control
                .set_parameter(VadParameter::Sensitivity, 6.0)
                .unwrap();
            let _ = control.is_speech_detected();
            worker.join().unwrap();
        });
    }

    #[cfg(feature = "async")]
    #[tokio::test]
    async fn async_processor_exposes_energy_vad_context() {
        let model = model();
        let config = ProcessorConfig::optimal(&model);
        let processor = crate::ProcessorAsync::new(&model, &license_key())
            .unwrap()
            .with_config(&config)
            .await
            .unwrap();
        let vad = processor.energy_vad_context().await;
        vad.set_parameter(VadParameter::Sensitivity, 8.0).unwrap();
        assert_eq!(vad.parameter(VadParameter::Sensitivity), 8.0);
        vad.reset();
        assert!(!vad.is_speech_detected());
    }
}