use crate::EvaluationResult;
use voirs_sdk::AudioBuffer;
#[derive(Debug, Clone)]
pub struct VuvConfig {
pub frame_length: f32,
pub frame_hop: f32,
pub energy_threshold: f32,
pub zcr_threshold: f32,
pub autocorr_threshold: f32,
pub spectral_centroid_threshold: f32,
pub spectral_rolloff_threshold: f32,
pub voicing_prob_threshold: f32,
}
impl Default for VuvConfig {
fn default() -> Self {
Self {
frame_length: 0.025, frame_hop: 0.01, energy_threshold: -30.0, zcr_threshold: 0.3,
autocorr_threshold: 0.3,
spectral_centroid_threshold: 1000.0,
spectral_rolloff_threshold: 0.85,
voicing_prob_threshold: 0.5,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VuvFrame {
pub time: f32,
pub is_voiced: bool,
pub voicing_probability: f32,
pub features: VuvFeatures,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VuvFeatures {
pub energy: f32,
pub zcr: f32,
pub autocorr_peak: f32,
pub spectral_centroid: f32,
pub spectral_rolloff: f32,
pub hnr: f32,
pub spectral_flatness: f32,
}
#[derive(Debug, Clone)]
pub struct VuvAnalysis {
pub frames: Vec<VuvFrame>,
pub sample_rate: u32,
pub config: VuvConfig,
pub algorithm: VuvAlgorithm,
pub statistics: VuvStatistics,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum VuvAlgorithm {
Energy,
ZeroCrossing,
Autocorrelation,
SpectralFeatures,
MultiFature,
MachineLearning,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VuvStatistics {
pub total_frames: usize,
pub voiced_frames: usize,
pub unvoiced_frames: usize,
pub voicing_rate: f32,
pub avg_voiced_probability: f32,
pub avg_unvoiced_probability: f32,
pub longest_voiced_segment: usize,
pub longest_unvoiced_segment: usize,
pub voicing_transitions: usize,
}
#[derive(Debug, Clone)]
pub struct VuvComparison {
pub reference: VuvAnalysis,
pub test: VuvAnalysis,
pub accuracy: VuvAccuracy,
pub alignment: VuvAlignment,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VuvAccuracy {
pub overall_accuracy: f32,
pub voiced_accuracy: f32,
pub unvoiced_accuracy: f32,
pub voiced_precision: f32,
pub voiced_recall: f32,
pub voiced_f1: f32,
pub false_positive_rate: f32,
pub false_negative_rate: f32,
pub probability_correlation: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VuvAlignment {
pub alignment_accuracy: f32,
pub avg_time_shift: f32,
pub boundary_accuracy: f32,
pub transition_errors: Vec<f32>,
}
pub struct VuvAnalyzer {
config: VuvConfig,
}
impl VuvAnalyzer {
#[must_use]
pub fn new() -> Self {
Self::with_config(VuvConfig::default())
}
#[must_use]
pub fn with_config(config: VuvConfig) -> Self {
Self { config }
}
pub async fn analyze(
&self,
audio: &AudioBuffer,
algorithm: VuvAlgorithm,
) -> EvaluationResult<VuvAnalysis> {
let samples = audio.samples();
let sample_rate = audio.sample_rate() as f32;
let frame_length_samples = (self.config.frame_length * sample_rate) as usize;
let frame_hop_samples = (self.config.frame_hop * sample_rate) as usize;
let mut frames = Vec::new();
let mut pos = 0;
while pos + frame_length_samples <= samples.len() {
let frame_samples = &samples[pos..pos + frame_length_samples];
let time = pos as f32 / sample_rate;
let features = self.extract_features(frame_samples, sample_rate).await?;
let (is_voiced, voicing_probability) = self.make_vuv_decision(&features, algorithm)?;
frames.push(VuvFrame {
time,
is_voiced,
voicing_probability,
features,
});
pos += frame_hop_samples;
}
let statistics = self.calculate_statistics(&frames);
Ok(VuvAnalysis {
frames,
sample_rate: audio.sample_rate(),
config: self.config.clone(),
algorithm,
statistics,
})
}
pub async fn compare(
&self,
reference: &AudioBuffer,
test: &AudioBuffer,
algorithm: VuvAlgorithm,
) -> EvaluationResult<VuvComparison> {
let ref_analysis = self.analyze(reference, algorithm).await?;
let test_analysis = self.analyze(test, algorithm).await?;
let accuracy = self.calculate_accuracy(&ref_analysis, &test_analysis)?;
let alignment = self.analyze_alignment(&ref_analysis, &test_analysis)?;
Ok(VuvComparison {
reference: ref_analysis,
test: test_analysis,
accuracy,
alignment,
})
}
async fn extract_features(
&self,
frame: &[f32],
sample_rate: f32,
) -> EvaluationResult<VuvFeatures> {
let energy = self.calculate_energy(frame);
let zcr = self.calculate_zcr(frame);
let autocorr_peak = self.calculate_autocorr_peak(frame)?;
let spectral_centroid = self.calculate_spectral_centroid(frame, sample_rate).await?;
let spectral_rolloff = self.calculate_spectral_rolloff(frame, sample_rate).await?;
let hnr = self.calculate_hnr(frame)?;
let spectral_flatness = self.calculate_spectral_flatness(frame).await?;
Ok(VuvFeatures {
energy,
zcr,
autocorr_peak,
spectral_centroid,
spectral_rolloff,
hnr,
spectral_flatness,
})
}
fn make_vuv_decision(
&self,
features: &VuvFeatures,
algorithm: VuvAlgorithm,
) -> EvaluationResult<(bool, f32)> {
let voicing_prob = match algorithm {
VuvAlgorithm::Energy => {
if features.energy > self.config.energy_threshold {
((features.energy - self.config.energy_threshold) / 30.0)
.min(1.0)
.max(0.0)
} else {
0.0
}
}
VuvAlgorithm::ZeroCrossing => {
let zcr_score = 1.0 - (features.zcr / self.config.zcr_threshold).min(1.0);
zcr_score.max(0.0)
}
VuvAlgorithm::Autocorrelation => (features.autocorr_peak
/ self.config.autocorr_threshold)
.min(1.0)
.max(0.0),
VuvAlgorithm::SpectralFeatures => {
let centroid_score =
if features.spectral_centroid < self.config.spectral_centroid_threshold {
1.0 - features.spectral_centroid / self.config.spectral_centroid_threshold
} else {
0.0
};
let rolloff_score = features.spectral_rolloff;
let flatness_score = 1.0 - features.spectral_flatness;
(centroid_score + rolloff_score + flatness_score) / 3.0
}
VuvAlgorithm::MultiFature => {
let energy_score = if features.energy > self.config.energy_threshold {
((features.energy - self.config.energy_threshold) / 30.0)
.min(1.0)
.max(0.0)
} else {
0.0
};
let zcr_score = 1.0 - (features.zcr / self.config.zcr_threshold).min(1.0).max(0.0);
let autocorr_score = (features.autocorr_peak / self.config.autocorr_threshold)
.min(1.0)
.max(0.0);
let hnr_score = (features.hnr / 10.0).min(1.0).max(0.0);
0.3 * energy_score + 0.2 * zcr_score + 0.3 * autocorr_score + 0.2 * hnr_score
}
VuvAlgorithm::MachineLearning => {
let feature_vector = [
features.energy,
features.zcr,
features.autocorr_peak,
features.spectral_centroid / 1000.0, features.spectral_rolloff,
features.hnr / 20.0, features.spectral_flatness,
];
let weights = [0.2, -0.15, 0.25, -0.1, 0.15, 0.2, -0.15];
let score: f32 = feature_vector
.iter()
.zip(weights.iter())
.map(|(f, w)| f * w)
.sum();
1.0 / (1.0 + (-score).exp())
}
};
let is_voiced = voicing_prob > self.config.voicing_prob_threshold;
Ok((is_voiced, voicing_prob))
}
fn calculate_energy(&self, frame: &[f32]) -> f32 {
let energy = frame.iter().map(|x| x * x).sum::<f32>() / frame.len() as f32;
if energy > 1e-10 {
10.0 * energy.log10() } else {
-100.0 }
}
fn calculate_zcr(&self, frame: &[f32]) -> f32 {
if frame.len() < 2 {
return 0.0;
}
let crossings = frame.windows(2).filter(|w| w[0] * w[1] < 0.0).count();
crossings as f32 / (frame.len() - 1) as f32
}
fn calculate_autocorr_peak(&self, frame: &[f32]) -> EvaluationResult<f32> {
if frame.is_empty() {
return Ok(0.0);
}
let max_lag = frame.len() / 4; let mut max_corr: f32 = 0.0;
for lag in 1..max_lag {
let mut correlation = 0.0;
let mut norm1 = 0.0;
let mut norm2 = 0.0;
for i in 0..(frame.len() - lag) {
correlation += frame[i] * frame[i + lag];
norm1 += frame[i] * frame[i];
norm2 += frame[i + lag] * frame[i + lag];
}
if norm1 > 0.0 && norm2 > 0.0 {
let normalized_corr = correlation / (norm1 * norm2).sqrt();
max_corr = max_corr.max(normalized_corr);
}
}
Ok(max_corr)
}
async fn calculate_spectral_centroid(
&self,
frame: &[f32],
sample_rate: f32,
) -> EvaluationResult<f32> {
let spectrum = self.compute_spectrum(frame).await?;
let mut weighted_sum = 0.0;
let mut magnitude_sum = 0.0;
for (i, &magnitude) in spectrum.iter().enumerate() {
let frequency = i as f32 * sample_rate / (2.0 * spectrum.len() as f32);
weighted_sum += frequency * magnitude;
magnitude_sum += magnitude;
}
if magnitude_sum > 0.0 {
Ok(weighted_sum / magnitude_sum)
} else {
Ok(0.0)
}
}
async fn calculate_spectral_rolloff(
&self,
frame: &[f32],
sample_rate: f32,
) -> EvaluationResult<f32> {
let spectrum = self.compute_spectrum(frame).await?;
let total_energy: f32 = spectrum.iter().sum();
let rolloff_threshold = 0.85 * total_energy;
let mut cumulative_energy = 0.0;
for (i, &magnitude) in spectrum.iter().enumerate() {
cumulative_energy += magnitude;
if cumulative_energy >= rolloff_threshold {
let frequency = i as f32 * sample_rate / (2.0 * spectrum.len() as f32);
return Ok(frequency / (sample_rate / 2.0)); }
}
Ok(1.0) }
fn calculate_hnr(&self, frame: &[f32]) -> EvaluationResult<f32> {
if frame.is_empty() {
return Ok(0.0);
}
let signal_power = frame.iter().map(|x| x * x).sum::<f32>() / frame.len() as f32;
let noise_estimate = if frame.len() > 10 {
let differences: Vec<f32> = frame.windows(2).map(|w| w[1] - w[0]).collect();
differences.iter().map(|x| x * x).sum::<f32>() / differences.len() as f32
} else {
signal_power * 0.1 };
if noise_estimate > 0.0 && signal_power > noise_estimate {
let hnr = 10.0 * (signal_power / noise_estimate).log10();
Ok(hnr.max(0.0).min(30.0)) } else {
Ok(0.0)
}
}
async fn calculate_spectral_flatness(&self, frame: &[f32]) -> EvaluationResult<f32> {
let spectrum = self.compute_spectrum(frame).await?;
if spectrum.iter().any(|&x| x <= 0.0) {
return Ok(0.0);
}
let geometric_mean = spectrum.iter().map(|x| x.ln()).sum::<f32>() / spectrum.len() as f32;
let arithmetic_mean = spectrum.iter().sum::<f32>() / spectrum.len() as f32;
if arithmetic_mean > 0.0 {
Ok(geometric_mean.exp() / arithmetic_mean)
} else {
Ok(0.0)
}
}
async fn compute_spectrum(&self, frame: &[f32]) -> EvaluationResult<Vec<f32>> {
let n = frame.len();
let mut spectrum = vec![0.0; n / 2 + 1];
let windowed: Vec<f32> = frame
.iter()
.enumerate()
.map(|(i, &x)| {
let window =
0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32).cos());
x * window
})
.collect();
for k in 0..spectrum.len() {
let mut real = 0.0;
let mut imag = 0.0;
for (i, &sample) in windowed.iter().enumerate() {
let angle = -2.0 * std::f32::consts::PI * k as f32 * i as f32 / n as f32;
real += sample * angle.cos();
imag += sample * angle.sin();
}
spectrum[k] = (real * real + imag * imag).sqrt();
}
Ok(spectrum)
}
fn calculate_statistics(&self, frames: &[VuvFrame]) -> VuvStatistics {
let total_frames = frames.len();
let voiced_frames = frames.iter().filter(|f| f.is_voiced).count();
let unvoiced_frames = total_frames - voiced_frames;
let voicing_rate = if total_frames > 0 {
voiced_frames as f32 / total_frames as f32
} else {
0.0
};
let avg_voiced_probability = if voiced_frames > 0 {
frames
.iter()
.filter(|f| f.is_voiced)
.map(|f| f.voicing_probability)
.sum::<f32>()
/ voiced_frames as f32
} else {
0.0
};
let avg_unvoiced_probability = if unvoiced_frames > 0 {
frames
.iter()
.filter(|f| !f.is_voiced)
.map(|f| f.voicing_probability)
.sum::<f32>()
/ unvoiced_frames as f32
} else {
0.0
};
let (longest_voiced, longest_unvoiced, transitions) = self.analyze_segments(frames);
VuvStatistics {
total_frames,
voiced_frames,
unvoiced_frames,
voicing_rate,
avg_voiced_probability,
avg_unvoiced_probability,
longest_voiced_segment: longest_voiced,
longest_unvoiced_segment: longest_unvoiced,
voicing_transitions: transitions,
}
}
fn analyze_segments(&self, frames: &[VuvFrame]) -> (usize, usize, usize) {
if frames.is_empty() {
return (0, 0, 0);
}
let mut longest_voiced = 0;
let mut longest_unvoiced = 0;
let mut transitions = 0;
let mut current_voiced_length = 0;
let mut current_unvoiced_length = 0;
let mut last_state = frames[0].is_voiced;
for frame in frames {
if frame.is_voiced != last_state {
transitions += 1;
last_state = frame.is_voiced;
}
if frame.is_voiced {
current_voiced_length += 1;
longest_unvoiced = longest_unvoiced.max(current_unvoiced_length);
current_unvoiced_length = 0;
} else {
current_unvoiced_length += 1;
longest_voiced = longest_voiced.max(current_voiced_length);
current_voiced_length = 0;
}
}
longest_voiced = longest_voiced.max(current_voiced_length);
longest_unvoiced = longest_unvoiced.max(current_unvoiced_length);
(longest_voiced, longest_unvoiced, transitions)
}
fn calculate_accuracy(
&self,
reference: &VuvAnalysis,
test: &VuvAnalysis,
) -> EvaluationResult<VuvAccuracy> {
let min_frames = reference.frames.len().min(test.frames.len());
if min_frames == 0 {
return Ok(VuvAccuracy {
overall_accuracy: 0.0,
voiced_accuracy: 0.0,
unvoiced_accuracy: 0.0,
voiced_precision: 0.0,
voiced_recall: 0.0,
voiced_f1: 0.0,
false_positive_rate: 0.0,
false_negative_rate: 0.0,
probability_correlation: 0.0,
});
}
let mut correct_total = 0;
let mut ref_voiced = 0;
let mut test_voiced = 0;
let mut correct_voiced = 0;
let mut correct_unvoiced = 0;
let mut true_positives = 0;
let mut false_positives = 0;
let mut false_negatives = 0;
let mut ref_probs = Vec::new();
let mut test_probs = Vec::new();
for i in 0..min_frames {
let ref_frame = &reference.frames[i];
let test_frame = &test.frames[i];
ref_probs.push(ref_frame.voicing_probability);
test_probs.push(test_frame.voicing_probability);
if ref_frame.is_voiced == test_frame.is_voiced {
correct_total += 1;
if ref_frame.is_voiced {
correct_voiced += 1;
true_positives += 1;
} else {
correct_unvoiced += 1;
}
}
if ref_frame.is_voiced {
ref_voiced += 1;
if !test_frame.is_voiced {
false_negatives += 1;
}
}
if test_frame.is_voiced {
test_voiced += 1;
if !ref_frame.is_voiced {
false_positives += 1;
}
}
}
let overall_accuracy = correct_total as f32 / min_frames as f32;
let voiced_accuracy = if ref_voiced > 0 {
correct_voiced as f32 / ref_voiced as f32
} else {
1.0
};
let unvoiced_accuracy = if (min_frames - ref_voiced) > 0 {
correct_unvoiced as f32 / (min_frames - ref_voiced) as f32
} else {
1.0
};
let voiced_precision = if test_voiced > 0 {
true_positives as f32 / test_voiced as f32
} else {
0.0
};
let voiced_recall = if ref_voiced > 0 {
true_positives as f32 / ref_voiced as f32
} else {
0.0
};
let voiced_f1 = if voiced_precision + voiced_recall > 0.0 {
2.0 * voiced_precision * voiced_recall / (voiced_precision + voiced_recall)
} else {
0.0
};
let false_positive_rate = if (min_frames - ref_voiced) > 0 {
false_positives as f32 / (min_frames - ref_voiced) as f32
} else {
0.0
};
let false_negative_rate = if ref_voiced > 0 {
false_negatives as f32 / ref_voiced as f32
} else {
0.0
};
let probability_correlation = crate::calculate_correlation(&ref_probs, &test_probs);
Ok(VuvAccuracy {
overall_accuracy,
voiced_accuracy,
unvoiced_accuracy,
voiced_precision,
voiced_recall,
voiced_f1,
false_positive_rate,
false_negative_rate,
probability_correlation,
})
}
fn analyze_alignment(
&self,
reference: &VuvAnalysis,
test: &VuvAnalysis,
) -> EvaluationResult<VuvAlignment> {
let min_frames = reference.frames.len().min(test.frames.len());
if min_frames == 0 {
return Ok(VuvAlignment {
alignment_accuracy: 0.0,
avg_time_shift: 0.0,
boundary_accuracy: 0.0,
transition_errors: Vec::new(),
});
}
let ref_transitions = self.find_transitions(&reference.frames);
let test_transitions = self.find_transitions(&test.frames);
let mut boundary_matches = 0;
let mut time_errors = Vec::new();
for ref_transition in &ref_transitions {
if let Some(closest_test) = test_transitions
.iter()
.min_by(|a, b| (a.abs_diff(*ref_transition)).cmp(&(b.abs_diff(*ref_transition))))
{
let time_error =
(*closest_test as f32 - *ref_transition as f32) * self.config.frame_hop;
time_errors.push(time_error.abs());
if closest_test.abs_diff(*ref_transition) <= 2 {
boundary_matches += 1;
}
}
}
let boundary_accuracy = if ref_transitions.is_empty() {
1.0
} else {
boundary_matches as f32 / ref_transitions.len() as f32
};
let avg_time_shift = if time_errors.is_empty() {
0.0
} else {
time_errors.iter().sum::<f32>() / time_errors.len() as f32
};
Ok(VuvAlignment {
alignment_accuracy: boundary_accuracy,
avg_time_shift,
boundary_accuracy,
transition_errors: time_errors,
})
}
fn find_transitions(&self, frames: &[VuvFrame]) -> Vec<usize> {
if frames.len() < 2 {
return Vec::new();
}
let mut transitions = Vec::new();
for i in 1..frames.len() {
if frames[i].is_voiced != frames[i - 1].is_voiced {
transitions.push(i);
}
}
transitions
}
}
impl Default for VuvAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
use voirs_sdk::AudioBuffer;
fn create_test_audio(sample_rate: u32, duration: f32, is_voiced: bool) -> AudioBuffer {
let num_samples = (sample_rate as f32 * duration) as usize;
let mut samples = Vec::with_capacity(num_samples);
for i in 0..num_samples {
let t = i as f32 / sample_rate as f32;
let sample = if is_voiced {
(2.0 * PI * 200.0 * t).sin() * 0.5
} else {
let pseudo_random =
((i * 1_103_515_245 + 12345) % 2_147_483_648) as f32 / 2_147_483_648.0;
(pseudo_random - 0.5) * 0.2
};
samples.push(sample);
}
AudioBuffer::new(samples, sample_rate, 1)
}
#[tokio::test]
async fn test_vuv_analyzer_creation() {
let analyzer = VuvAnalyzer::new();
assert_eq!(analyzer.config.frame_length, 0.025);
assert_eq!(analyzer.config.frame_hop, 0.01);
}
#[tokio::test]
async fn test_vuv_analysis_voiced() {
let analyzer = VuvAnalyzer::new();
let audio = create_test_audio(16000, 0.5, true);
let analysis = analyzer
.analyze(&audio, VuvAlgorithm::Energy)
.await
.unwrap();
assert!(!analysis.frames.is_empty());
assert_eq!(analysis.algorithm, VuvAlgorithm::Energy);
assert!(
analysis.statistics.voicing_rate > 0.3,
"Voicing rate was {}, expected > 0.3",
analysis.statistics.voicing_rate
);
}
#[tokio::test]
async fn test_vuv_analysis_unvoiced() {
let analyzer = VuvAnalyzer::new();
let audio = create_test_audio(16000, 0.5, false);
let analysis = analyzer
.analyze(&audio, VuvAlgorithm::ZeroCrossing)
.await
.unwrap();
assert!(!analysis.frames.is_empty());
assert_eq!(analysis.algorithm, VuvAlgorithm::ZeroCrossing);
assert!(analysis.statistics.voicing_rate < 0.5); }
#[tokio::test]
async fn test_vuv_comparison() {
let analyzer = VuvAnalyzer::new();
let voiced_audio = create_test_audio(16000, 0.5, true);
let unvoiced_audio = create_test_audio(16000, 0.5, false);
let comparison = analyzer
.compare(&voiced_audio, &unvoiced_audio, VuvAlgorithm::MultiFature)
.await
.unwrap();
assert!(comparison.accuracy.overall_accuracy >= 0.0);
assert!(comparison.accuracy.overall_accuracy <= 1.0);
assert!(comparison.accuracy.voiced_precision >= 0.0);
assert!(comparison.accuracy.voiced_recall >= 0.0);
}
#[tokio::test]
async fn test_vuv_features_extraction() {
let analyzer = VuvAnalyzer::new();
let frame = vec![0.1, 0.2, -0.1, 0.3, -0.2, 0.1];
let features = analyzer.extract_features(&frame, 16000.0).await.unwrap();
assert!(features.energy.is_finite());
assert!(features.zcr >= 0.0 && features.zcr <= 1.0);
assert!(features.autocorr_peak >= 0.0 && features.autocorr_peak <= 1.0);
assert!(features.spectral_centroid >= 0.0);
assert!(features.spectral_rolloff >= 0.0 && features.spectral_rolloff <= 1.0);
}
#[tokio::test]
async fn test_vuv_algorithms() {
let analyzer = VuvAnalyzer::new();
let audio = create_test_audio(16000, 0.2, true);
let algorithms = [
VuvAlgorithm::Energy,
VuvAlgorithm::ZeroCrossing,
VuvAlgorithm::Autocorrelation,
VuvAlgorithm::SpectralFeatures,
VuvAlgorithm::MultiFature,
VuvAlgorithm::MachineLearning,
];
for algorithm in algorithms {
let analysis = analyzer.analyze(&audio, algorithm).await.unwrap();
assert_eq!(analysis.algorithm, algorithm);
assert!(!analysis.frames.is_empty());
}
}
#[tokio::test]
async fn test_vuv_statistics() {
let analyzer = VuvAnalyzer::new();
let audio = create_test_audio(16000, 0.3, true);
let analysis = analyzer
.analyze(&audio, VuvAlgorithm::Energy)
.await
.unwrap();
let stats = &analysis.statistics;
assert_eq!(
stats.total_frames,
stats.voiced_frames + stats.unvoiced_frames
);
assert!(stats.voicing_rate >= 0.0 && stats.voicing_rate <= 1.0);
assert!(stats.avg_voiced_probability >= 0.0 && stats.avg_voiced_probability <= 1.0);
assert!(stats.longest_voiced_segment <= stats.total_frames);
}
}