use super::voices::CharacterVoice;
use crate::prose::VoiceProfile;
const FEATURES: usize = 6;
pub(crate) fn feature_vector(p: &VoiceProfile) -> [f32; FEATURES] {
[
p.p50, p.cv, p.burstiness, p.mattr, p.modal_density.unwrap_or(0.0), p.interiority_ratio.unwrap_or(0.0),
]
}
#[derive(Debug, Clone)]
pub(crate) struct VoicePair {
pub a: String,
pub b: String,
pub distance: f32,
}
pub(crate) struct DistinctMatrix {
pub names: Vec<String>,
pub pairs: Vec<VoicePair>,
pub indistinguishable: Vec<VoicePair>,
}
impl DistinctMatrix {
pub(crate) fn closest(&self) -> Option<&VoicePair> {
self.pairs.first()
}
pub(crate) fn most_distinct(&self) -> Option<&VoicePair> {
self.pairs.last()
}
}
pub(crate) fn matrix(voices: &[CharacterVoice], threshold: f32, ignore: &[String]) -> DistinctMatrix {
let comparable: Vec<&CharacterVoice> =
voices.iter().filter(|v| v.confidence.is_comparable()).collect();
let names: Vec<String> = comparable.iter().map(|v| v.name.clone()).collect();
if comparable.len() < 2 {
return DistinctMatrix { names, pairs: Vec::new(), indistinguishable: Vec::new() };
}
let raw: Vec<[f32; FEATURES]> = comparable.iter().map(|v| feature_vector(&v.profile)).collect();
let z = z_score(&raw);
let mut pairs = Vec::new();
for i in 0..comparable.len() {
for j in (i + 1)..comparable.len() {
pairs.push(VoicePair {
a: names[i].clone(),
b: names[j].clone(),
distance: rms_distance(&z[i], &z[j]),
});
}
}
pairs.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal));
let indistinguishable = pairs
.iter()
.filter(|p| p.distance < threshold && !is_ignored(ignore, &p.a, &p.b))
.cloned()
.collect();
DistinctMatrix { names, pairs, indistinguishable }
}
fn z_score(raw: &[[f32; FEATURES]]) -> Vec<[f32; FEATURES]> {
let n = raw.len() as f32;
let mut mean = [0.0f32; FEATURES];
for r in raw {
for d in 0..FEATURES {
mean[d] += r[d];
}
}
for m in &mut mean {
*m /= n;
}
let mut std = [0.0f32; FEATURES];
for r in raw {
for d in 0..FEATURES {
let dv = r[d] - mean[d];
std[d] += dv * dv;
}
}
for s in &mut std {
*s = (*s / n).sqrt();
}
raw.iter()
.map(|r| {
let mut zz = [0.0f32; FEATURES];
for d in 0..FEATURES {
zz[d] = if std[d] > 1e-9 { (r[d] - mean[d]) / std[d] } else { 0.0 };
}
zz
})
.collect()
}
fn rms_distance(a: &[f32; FEATURES], b: &[f32; FEATURES]) -> f32 {
let sum: f32 = (0..FEATURES).map(|d| (a[d] - b[d]).powi(2)).sum();
(sum / FEATURES as f32).sqrt()
}
fn is_ignored(ignore: &[String], a: &str, b: &str) -> bool {
let (al, bl) = (a.to_lowercase(), b.to_lowercase());
ignore.iter().any(|entry| {
let mut parts = entry.split('|').map(|s| s.trim().to_lowercase());
match (parts.next(), parts.next()) {
(Some(x), Some(y)) => (x == al && y == bl) || (x == bl && y == al),
_ => false,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chorus::voices::Confidence;
use crate::prose::{CompiledLexicon, ProseLanguage, VoiceScope, compute_profile_with};
fn voice(name: &str, text: &str, confidence: Confidence) -> CharacterVoice {
let lx = CompiledLexicon::for_language_with(&ProseLanguage::En, &[], &[]);
let profile = compute_profile_with(
text,
VoiceScope::Character(name.into()),
&ProseLanguage::En,
&lx,
false,
100,
);
CharacterVoice { name: name.into(), profile, confidence, utterances: 30, per_chapter: Vec::new() }
}
const CLIPPED: &str = "Yes. No. Go. Stop. Now. Wait. Fine. Leave. Run. Hide. Down. Up.";
const FLOWING: &str = "The evening light fell slowly across the wide and silent water, and \
she wondered whether the tide would ever turn again before the long \
grey dawn came creeping over the eastern hills once more.";
#[test]
fn identical_voices_are_flagged_distinct_ones_are_not() {
let voices = vec![
voice("Mara", CLIPPED, Confidence::High),
voice("Joren", CLIPPED, Confidence::High),
voice("Sela", FLOWING, Confidence::High),
];
let m = matrix(&voices, 0.5, &[]);
assert_eq!(m.names.len(), 3);
assert_eq!(m.indistinguishable.len(), 1);
let flagged = &m.indistinguishable[0];
let pair = {
let mut p = [flagged.a.as_str(), flagged.b.as_str()];
p.sort();
p
};
assert_eq!(pair, ["Joren", "Mara"]);
assert!(flagged.distance < 1e-3, "identical voices should be ~0 apart");
assert_eq!(m.closest().unwrap().distance, flagged.distance);
assert!(m.most_distinct().unwrap().distance > 0.5);
}
#[test]
fn low_confidence_voices_never_participate() {
let voices = vec![
voice("Mara", CLIPPED, Confidence::High),
voice("Joren", CLIPPED, Confidence::Low), ];
let m = matrix(&voices, 0.5, &[]);
assert_eq!(m.names, vec!["Mara".to_string()]);
assert!(m.pairs.is_empty());
assert!(m.indistinguishable.is_empty());
}
#[test]
fn ignore_list_suppresses_a_deliberate_pair() {
let voices = vec![
voice("Mara", CLIPPED, Confidence::High),
voice("Joren", CLIPPED, Confidence::High),
];
let m = matrix(&voices, 0.5, &["joren|mara".to_string()]);
assert!(m.pairs.len() == 1 && m.indistinguishable.is_empty());
}
}