|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
use super::prosody::MarineProsodyVector; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
|
|
pub enum ComfortLevel { |
|
|
|
|
|
Uneasy, |
|
|
|
|
|
Neutral, |
|
|
|
|
|
Happy, |
|
|
} |
|
|
|
|
|
impl ComfortLevel { |
|
|
|
|
|
pub fn emoji(&self) -> &'static str { |
|
|
match self { |
|
|
ComfortLevel::Uneasy => "😟", |
|
|
ComfortLevel::Neutral => "😐", |
|
|
ComfortLevel::Happy => "😊", |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn description(&self) -> &'static str { |
|
|
match self { |
|
|
ComfortLevel::Uneasy => "uneasy or tense", |
|
|
ComfortLevel::Neutral => "neutral or stable", |
|
|
ComfortLevel::Happy => "comfortable and positive", |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn score(&self) -> i8 { |
|
|
match self { |
|
|
ComfortLevel::Uneasy => -1, |
|
|
ComfortLevel::Neutral => 0, |
|
|
ComfortLevel::Happy => 1, |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)] |
|
|
pub struct ConversationAffectSummary { |
|
|
|
|
|
pub human_state: Option<ComfortLevel>, |
|
|
|
|
|
pub aye_state: ComfortLevel, |
|
|
|
|
|
pub quality_score: f32, |
|
|
|
|
|
pub utterance_count: usize, |
|
|
|
|
|
pub duration_seconds: f32, |
|
|
|
|
|
pub mean_prosody: MarineProsodyVector, |
|
|
|
|
|
pub jitter_trend: f32, |
|
|
|
|
|
pub energy_trend: f32, |
|
|
} |
|
|
|
|
|
impl ConversationAffectSummary { |
|
|
|
|
|
pub fn aye_assessment(&self) -> String { |
|
|
let emoji = self.aye_state.emoji(); |
|
|
let desc = self.aye_state.description(); |
|
|
|
|
|
let quality_desc = if self.quality_score > 0.8 { |
|
|
"very good" |
|
|
} else if self.quality_score > 0.6 { |
|
|
"good" |
|
|
} else if self.quality_score > 0.4 { |
|
|
"moderate" |
|
|
} else { |
|
|
"low" |
|
|
}; |
|
|
|
|
|
format!( |
|
|
"{} Aye thinks this conversation felt {}. Audio quality was {} ({:.0}%). \ |
|
|
{} {} utterances over {:.1} seconds.", |
|
|
emoji, |
|
|
desc, |
|
|
quality_desc, |
|
|
self.quality_score * 100.0, |
|
|
if self.jitter_trend > 0.05 { |
|
|
"Tension seemed to increase." |
|
|
} else if self.jitter_trend < -0.05 { |
|
|
"Tension seemed to decrease." |
|
|
} else { |
|
|
"Emotional tone stayed consistent." |
|
|
}, |
|
|
self.utterance_count, |
|
|
self.duration_seconds |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
pub fn feedback_prompt(&self) -> String { |
|
|
format!( |
|
|
"Aye would like to improve. How did this conversation make you feel?\n\ |
|
|
A) Uneasy or tense 😟\n\ |
|
|
B) Neutral or okay 😐\n\ |
|
|
C) Comfortable and positive 😊\n\n\ |
|
|
Aye's self-assessment: {} ({})", |
|
|
self.aye_state.emoji(), |
|
|
self.aye_state.description() |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pub struct ConversationAffectAnalyzer { |
|
|
|
|
|
utterances: Vec<MarineProsodyVector>, |
|
|
|
|
|
total_duration_seconds: f32, |
|
|
|
|
|
config: AffectAnalyzerConfig, |
|
|
} |
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)] |
|
|
pub struct AffectAnalyzerConfig { |
|
|
|
|
|
pub high_jitter_threshold: f32, |
|
|
|
|
|
pub rising_jitter_threshold: f32, |
|
|
|
|
|
pub high_energy_threshold: f32, |
|
|
} |
|
|
|
|
|
impl Default for AffectAnalyzerConfig { |
|
|
fn default() -> Self { |
|
|
Self { |
|
|
high_jitter_threshold: 0.4, |
|
|
rising_jitter_threshold: 0.1, |
|
|
high_energy_threshold: 0.5, |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
impl ConversationAffectAnalyzer { |
|
|
|
|
|
pub fn new() -> Self { |
|
|
Self { |
|
|
utterances: Vec::new(), |
|
|
total_duration_seconds: 0.0, |
|
|
config: AffectAnalyzerConfig::default(), |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn with_config(config: AffectAnalyzerConfig) -> Self { |
|
|
Self { |
|
|
utterances: Vec::new(), |
|
|
total_duration_seconds: 0.0, |
|
|
config, |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn add_utterance(&mut self, prosody: MarineProsodyVector, duration_seconds: f32) { |
|
|
self.utterances.push(prosody); |
|
|
self.total_duration_seconds += duration_seconds; |
|
|
} |
|
|
|
|
|
|
|
|
pub fn reset(&mut self) { |
|
|
self.utterances.clear(); |
|
|
self.total_duration_seconds = 0.0; |
|
|
} |
|
|
|
|
|
|
|
|
pub fn analyze(&self) -> Option<ConversationAffectSummary> { |
|
|
if self.utterances.is_empty() { |
|
|
return None; |
|
|
} |
|
|
|
|
|
let n = self.utterances.len() as f32; |
|
|
|
|
|
|
|
|
let mut mean_prosody = MarineProsodyVector::zeros(); |
|
|
for p in &self.utterances { |
|
|
mean_prosody.jp_mean += p.jp_mean; |
|
|
mean_prosody.jp_std += p.jp_std; |
|
|
mean_prosody.ja_mean += p.ja_mean; |
|
|
mean_prosody.ja_std += p.ja_std; |
|
|
mean_prosody.h_mean += p.h_mean; |
|
|
mean_prosody.s_mean += p.s_mean; |
|
|
mean_prosody.peak_density += p.peak_density; |
|
|
mean_prosody.energy_mean += p.energy_mean; |
|
|
} |
|
|
mean_prosody.jp_mean /= n; |
|
|
mean_prosody.jp_std /= n; |
|
|
mean_prosody.ja_mean /= n; |
|
|
mean_prosody.ja_std /= n; |
|
|
mean_prosody.h_mean /= n; |
|
|
mean_prosody.s_mean /= n; |
|
|
mean_prosody.peak_density /= n; |
|
|
mean_prosody.energy_mean /= n; |
|
|
|
|
|
|
|
|
let jitter_trend = if self.utterances.len() >= 2 { |
|
|
let first = self.utterances.first().unwrap().combined_jitter(); |
|
|
let last = self.utterances.last().unwrap().combined_jitter(); |
|
|
last - first |
|
|
} else { |
|
|
0.0 |
|
|
}; |
|
|
|
|
|
let energy_trend = if self.utterances.len() >= 2 { |
|
|
let first = self.utterances.first().unwrap().energy_mean; |
|
|
let last = self.utterances.last().unwrap().energy_mean; |
|
|
last - first |
|
|
} else { |
|
|
0.0 |
|
|
}; |
|
|
|
|
|
|
|
|
let aye_state = self.classify_comfort( |
|
|
mean_prosody.combined_jitter(), |
|
|
jitter_trend, |
|
|
mean_prosody.energy_mean, |
|
|
); |
|
|
|
|
|
let quality_score = mean_prosody.s_mean; |
|
|
|
|
|
Some(ConversationAffectSummary { |
|
|
human_state: None, |
|
|
aye_state, |
|
|
quality_score, |
|
|
utterance_count: self.utterances.len(), |
|
|
duration_seconds: self.total_duration_seconds, |
|
|
mean_prosody, |
|
|
jitter_trend, |
|
|
energy_trend, |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
fn classify_comfort( |
|
|
&self, |
|
|
mean_jitter: f32, |
|
|
trend_jitter: f32, |
|
|
mean_energy: f32, |
|
|
) -> ComfortLevel { |
|
|
let high_jitter = mean_jitter > self.config.high_jitter_threshold; |
|
|
let rising_jitter = trend_jitter > self.config.rising_jitter_threshold; |
|
|
|
|
|
if high_jitter && rising_jitter { |
|
|
|
|
|
ComfortLevel::Uneasy |
|
|
} else if mean_energy > self.config.high_energy_threshold && !high_jitter { |
|
|
|
|
|
ComfortLevel::Happy |
|
|
} else { |
|
|
|
|
|
ComfortLevel::Neutral |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn utterance_count(&self) -> usize { |
|
|
self.utterances.len() |
|
|
} |
|
|
|
|
|
|
|
|
pub fn total_duration(&self) -> f32 { |
|
|
self.total_duration_seconds |
|
|
} |
|
|
} |
|
|
|
|
|
impl Default for ConversationAffectAnalyzer { |
|
|
fn default() -> Self { |
|
|
Self::new() |
|
|
} |
|
|
} |
|
|
|
|
|
#[cfg(test)] |
|
|
mod tests { |
|
|
use super::*; |
|
|
|
|
|
#[test] |
|
|
fn test_comfort_level_descriptions() { |
|
|
assert_eq!(ComfortLevel::Uneasy.emoji(), "😟"); |
|
|
assert_eq!(ComfortLevel::Neutral.emoji(), "😐"); |
|
|
assert_eq!(ComfortLevel::Happy.emoji(), "😊"); |
|
|
|
|
|
assert_eq!(ComfortLevel::Uneasy.score(), -1); |
|
|
assert_eq!(ComfortLevel::Neutral.score(), 0); |
|
|
assert_eq!(ComfortLevel::Happy.score(), 1); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_analyzer_empty_conversation() { |
|
|
let analyzer = ConversationAffectAnalyzer::new(); |
|
|
assert!(analyzer.analyze().is_none()); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_analyzer_single_utterance() { |
|
|
let mut analyzer = ConversationAffectAnalyzer::new(); |
|
|
let prosody = MarineProsodyVector { |
|
|
jp_mean: 0.1, |
|
|
jp_std: 0.05, |
|
|
ja_mean: 0.1, |
|
|
ja_std: 0.05, |
|
|
h_mean: 1.0, |
|
|
s_mean: 0.8, |
|
|
peak_density: 50.0, |
|
|
energy_mean: 0.6, |
|
|
}; |
|
|
analyzer.add_utterance(prosody, 2.0); |
|
|
|
|
|
let summary = analyzer.analyze().unwrap(); |
|
|
assert_eq!(summary.utterance_count, 1); |
|
|
assert_eq!(summary.duration_seconds, 2.0); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_uneasy_classification() { |
|
|
let mut analyzer = ConversationAffectAnalyzer::new(); |
|
|
|
|
|
|
|
|
analyzer.add_utterance( |
|
|
MarineProsodyVector { |
|
|
jp_mean: 0.3, |
|
|
jp_std: 0.1, |
|
|
ja_mean: 0.3, |
|
|
ja_std: 0.1, |
|
|
h_mean: 1.0, |
|
|
s_mean: 0.5, |
|
|
peak_density: 50.0, |
|
|
energy_mean: 0.3, |
|
|
}, |
|
|
1.0, |
|
|
); |
|
|
|
|
|
|
|
|
analyzer.add_utterance( |
|
|
MarineProsodyVector { |
|
|
jp_mean: 0.6, |
|
|
jp_std: 0.2, |
|
|
ja_mean: 0.5, |
|
|
ja_std: 0.2, |
|
|
h_mean: 0.8, |
|
|
s_mean: 0.3, |
|
|
peak_density: 60.0, |
|
|
energy_mean: 0.4, |
|
|
}, |
|
|
1.0, |
|
|
); |
|
|
|
|
|
let summary = analyzer.analyze().unwrap(); |
|
|
assert_eq!(summary.aye_state, ComfortLevel::Uneasy); |
|
|
assert!(summary.jitter_trend > 0.0); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_happy_classification() { |
|
|
let mut analyzer = ConversationAffectAnalyzer::new(); |
|
|
|
|
|
|
|
|
analyzer.add_utterance( |
|
|
MarineProsodyVector { |
|
|
jp_mean: 0.1, |
|
|
jp_std: 0.05, |
|
|
ja_mean: 0.1, |
|
|
ja_std: 0.05, |
|
|
h_mean: 1.0, |
|
|
s_mean: 0.9, |
|
|
peak_density: 80.0, |
|
|
energy_mean: 0.7, |
|
|
}, |
|
|
2.0, |
|
|
); |
|
|
|
|
|
let summary = analyzer.analyze().unwrap(); |
|
|
assert_eq!(summary.aye_state, ComfortLevel::Happy); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_neutral_classification() { |
|
|
let mut analyzer = ConversationAffectAnalyzer::new(); |
|
|
|
|
|
|
|
|
analyzer.add_utterance( |
|
|
MarineProsodyVector { |
|
|
jp_mean: 0.2, |
|
|
jp_std: 0.1, |
|
|
ja_mean: 0.2, |
|
|
ja_std: 0.1, |
|
|
h_mean: 1.0, |
|
|
s_mean: 0.7, |
|
|
peak_density: 40.0, |
|
|
energy_mean: 0.3, |
|
|
}, |
|
|
1.5, |
|
|
); |
|
|
|
|
|
let summary = analyzer.analyze().unwrap(); |
|
|
assert_eq!(summary.aye_state, ComfortLevel::Neutral); |
|
|
} |
|
|
|
|
|
#[test] |
|
|
fn test_aye_assessment_message() { |
|
|
let summary = ConversationAffectSummary { |
|
|
human_state: None, |
|
|
aye_state: ComfortLevel::Happy, |
|
|
quality_score: 0.85, |
|
|
utterance_count: 5, |
|
|
duration_seconds: 30.0, |
|
|
mean_prosody: MarineProsodyVector::zeros(), |
|
|
jitter_trend: -0.1, |
|
|
energy_trend: 0.2, |
|
|
}; |
|
|
|
|
|
let message = summary.aye_assessment(); |
|
|
assert!(message.contains("😊")); |
|
|
assert!(message.contains("comfortable")); |
|
|
assert!(message.contains("85%")); |
|
|
assert!(message.contains("5 utterances")); |
|
|
} |
|
|
} |
|
|
|