use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::Mutex;
use polyvoice::streaming::StreamingPipeline;
use polyvoice::{
ClusterConfig, DiarizationConfig as DiaConfig, Embedder, EmbedderError, EnergyVad,
FbankOnnxExtractor, Pipeline, PipelineError, VadConfig,
};
use super::DiarizationOutcome;
pub(crate) const SPEAKER_EMBEDDING_DIM: usize = 256;
const SPEAKER_POOL_SIZE: usize = 4;
pub type SpeakerEncoder = Arc<FbankOnnxExtractor>;
pub type StreamingDiarizationState = StreamingPipeline<EnergyVad, SharedExtractor>;
pub struct SharedExtractor(Arc<FbankOnnxExtractor>);
impl Embedder for SharedExtractor {
fn dim(&self) -> usize {
self.0.dim()
}
fn embed(&self, samples: &[f32]) -> Result<Vec<f32>, EmbedderError> {
self.0.embed(samples)
}
}
pub(crate) fn load_speaker_encoder(
model_path: &Path,
pool_size: usize,
) -> anyhow::Result<FbankOnnxExtractor> {
FbankOnnxExtractor::new(
model_path,
SPEAKER_EMBEDDING_DIM,
pool_size,
polyvoice::onnx::ExecutionProvider::Cpu,
)
}
pub struct LazySpeakerEncoder {
path: PathBuf,
slot: Mutex<SpeakerLoadSlot>,
}
enum SpeakerLoadSlot {
Pending,
Ready(SpeakerEncoder),
Failed,
}
impl LazySpeakerEncoder {
#[cfg(test)]
pub(crate) fn is_loaded(&self) -> bool {
matches!(*self.slot.lock(), SpeakerLoadSlot::Ready(_))
}
#[cfg(test)]
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub fn get_or_load(&self) -> Option<SpeakerEncoder> {
let mut slot = self.slot.lock();
match &*slot {
SpeakerLoadSlot::Ready(enc) => return Some(Arc::clone(enc)),
SpeakerLoadSlot::Failed => return None,
SpeakerLoadSlot::Pending => {}
}
match load_speaker_encoder(&self.path, SPEAKER_POOL_SIZE) {
Ok(enc) => {
tracing::info!("Speaker encoder loaded (diarization available)");
let enc = Arc::new(enc);
*slot = SpeakerLoadSlot::Ready(Arc::clone(&enc));
Some(enc)
}
Err(e) => {
tracing::warn!("Speaker encoder not loaded, diarization unavailable: {e:#}");
*slot = SpeakerLoadSlot::Failed;
None
}
}
}
}
pub fn probe_speaker_encoder(model_dir: &Path) -> Option<LazySpeakerEncoder> {
let path = model_dir.join("wespeaker_resnet34.onnx");
if !path.exists() {
tracing::warn!("wespeaker_resnet34.onnx not found, diarization unavailable");
return None;
}
tracing::info!(
"Speaker encoder present at {} (lazy load on first diarization request)",
path.display()
);
Some(LazySpeakerEncoder {
path,
slot: Mutex::new(SpeakerLoadSlot::Pending),
})
}
pub fn open_streaming(encoder: &SpeakerEncoder) -> Option<StreamingDiarizationState> {
let config = DiaConfig {
cluster: ClusterConfig {
threshold: 0.5,
..ClusterConfig::default()
},
..DiaConfig::default()
};
let vad_config = VadConfig::default();
let vad = EnergyVad::new(-40.0, 16000, vad_config.frame_size);
let extractor = SharedExtractor(Arc::clone(encoder));
match StreamingPipeline::new(vad, extractor, config, vad_config) {
Ok(pipeline) => Some(pipeline),
Err(e) => {
tracing::warn!("Failed to initialize streaming diarization: {e:#}");
None
}
}
}
pub fn feed_chunk(state: &mut StreamingDiarizationState, samples: &[f32]) {
if let Err(e) = state.feed(samples) {
tracing::warn!("Diarization feed failed: {e:#}");
}
}
pub fn last_turn_speaker(state: &StreamingDiarizationState) -> Option<u32> {
state.turns().last().map(|t| t.speaker.0)
}
#[derive(Debug, Clone)]
pub struct LabeledTurn {
pub start: f64,
pub end: f64,
pub speaker: u32,
}
pub fn run_offline(
encoder: &SpeakerEncoder,
samples: &[f32],
) -> Result<Vec<LabeledTurn>, DiarizationOutcome> {
let config = DiaConfig::default();
let vad_config = VadConfig::default();
let pipeline = Pipeline::new(config, vad_config);
let mut vad = EnergyVad::new(-40.0, 16000, vad_config.frame_size);
match pipeline.run(samples, encoder.as_ref(), &mut vad) {
Ok(dia_result) => Ok(dia_result
.turns
.into_iter()
.map(|t| LabeledTurn {
start: t.time.start,
end: t.time.end,
speaker: t.speaker.0,
})
.collect()),
Err(e) => Err(classify_offline_error(e)),
}
}
fn classify_offline_error(e: PipelineError) -> DiarizationOutcome {
match e {
PipelineError::AudioTooLong {
actual_secs,
max_secs,
} => DiarizationOutcome::DurationCeiling {
input_secs: actual_secs as f64,
ceiling_secs: max_secs as f64,
},
other => {
tracing::warn!("Offline diarization failed: {other:#}");
DiarizationOutcome::Failed
}
}
}
pub fn assign_speakers_by_midpoint(turns: &[LabeledTurn], words: &mut [super::WordInfo]) {
for word in words {
let mid = (word.start + word.end) / 2.0;
if let Some(turn) = turns.iter().find(|t| t.start <= mid && t.end >= mid) {
word.speaker = Some(turn.speaker);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_offline_error_duration_ceiling_carries_numbers() {
let outcome = classify_offline_error(PipelineError::AudioTooLong {
actual_secs: 5400.0,
max_secs: 3600.0,
});
assert_eq!(
outcome,
DiarizationOutcome::DurationCeiling {
input_secs: 5400.0,
ceiling_secs: 3600.0,
}
);
}
#[test]
fn test_classify_offline_error_other_is_failed() {
assert_eq!(
classify_offline_error(PipelineError::NoSpeech),
DiarizationOutcome::Failed
);
}
#[test]
fn test_load_speaker_encoder_missing_model_errors() {
let missing = Path::new("/nonexistent/gigastt-test/wespeaker_resnet34.onnx");
let result = load_speaker_encoder(missing, 1);
assert!(
result.is_err(),
"a missing WeSpeaker model must surface as Err, not panic or Ok"
);
}
#[test]
fn test_probe_speaker_encoder_absent_returns_none() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(
probe_speaker_encoder(dir.path()).is_none(),
"missing wespeaker file must not advertise diarization"
);
}
#[test]
fn test_probe_speaker_encoder_defers_onnx_load() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("wespeaker_resnet34.onnx");
std::fs::write(&path, b"").expect("write placeholder");
let lazy = probe_speaker_encoder(dir.path()).expect("file present → probe succeeds");
assert_eq!(lazy.path(), path.as_path());
assert!(
!lazy.is_loaded(),
"probe must not open an ONNX session at boot"
);
assert!(lazy.get_or_load().is_none());
assert!(!lazy.is_loaded());
assert!(
lazy.get_or_load().is_none(),
"failed load must be sticky until engine reload"
);
}
#[test]
#[ignore = "requires the WeSpeaker diarization model"]
fn test_speaker_encoder_accepts_waveform_audio() {
let model_path =
Path::new(&crate::model::default_model_dir()).join("wespeaker_resnet34.onnx");
let encoder = load_speaker_encoder(&model_path, 1).expect("speaker encoder should load");
let samples: Vec<f32> = (0..24_000)
.map(|i| {
let phase = std::f32::consts::TAU * 220.0 * i as f32 / 16_000.0;
0.1 * phase.sin()
})
.collect();
let embedding = encoder
.embed(&samples)
.expect("waveform must be converted to rank-3 fbank features");
assert_eq!(embedding.len(), SPEAKER_EMBEDDING_DIM);
assert!(embedding.iter().all(|value| value.is_finite()));
}
#[test]
#[ignore = "requires the WeSpeaker diarization model"]
fn test_lazy_speaker_encoder_loads_on_demand() {
let model_dir = crate::model::default_model_dir();
let lazy = probe_speaker_encoder(Path::new(&model_dir))
.expect("WeSpeaker model should be present");
assert!(!lazy.is_loaded());
let enc = lazy.get_or_load().expect("first get_or_load should load");
assert!(lazy.is_loaded());
let enc2 = lazy.get_or_load().expect("second get_or_load");
assert!(Arc::ptr_eq(&enc, &enc2));
}
}