use crate::types::{Profile, SampleRate};
use std::path::PathBuf;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct PipelineConfig {
pub profile: Profile,
pub sample_rate: SampleRate,
pub clusterer: ClustererKind,
pub max_speakers: u8,
pub min_cluster_size: usize,
pub resegment_overlap: bool,
pub min_speech_secs: f32,
pub max_gap_secs: f32,
pub embedder_pool_size: usize,
pub max_audio_samples: usize,
pub execution_provider: ExecutionProvider,
pub vbx_plda_dir: Option<PathBuf>,
pub embed_window_secs: Option<f32>,
pub as_norm: Option<crate::clusterer::AsNormConfig>,
pub domain: Option<crate::clusterer::DomainProfile>,
pub experimental: ExperimentalConfig,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ExperimentalConfig {
pub disable_seg_overlap: bool,
pub majority_local_map: bool,
pub binarization: Option<crate::segmentation::BinarizationConfig>,
pub embedder_model: Option<String>,
pub reconstruct: bool,
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
profile: Profile::Balanced,
sample_rate: SampleRate::new(16000).unwrap_or_default(),
clusterer: default_clusterer(),
max_speakers: 20,
min_cluster_size: 1,
resegment_overlap: true,
min_speech_secs: 0.25,
max_gap_secs: 0.5,
embedder_pool_size: default_pool_size(),
max_audio_samples: crate::pipeline_v2::MAX_AUDIO_SAMPLES,
execution_provider: ExecutionProvider::auto(),
vbx_plda_dir: None,
embed_window_secs: None,
as_norm: None,
domain: None,
experimental: ExperimentalConfig::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum ClustererKind {
NmeSc,
Ahc {
threshold: f32,
},
Vbx,
}
#[cfg(feature = "infer")]
pub use crate::onnx::ExecutionProvider;
#[cfg(not(feature = "infer"))]
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum ExecutionProvider {
Cpu,
CoreMl,
Nnapi,
Cuda,
XnnPack,
}
#[cfg(not(feature = "infer"))]
impl ExecutionProvider {
pub fn auto() -> Self {
Self::Cpu
}
pub fn is_available(self) -> bool {
matches!(self, Self::Cpu)
}
}
fn default_clusterer() -> ClustererKind {
#[cfg(feature = "vbx")]
{
ClustererKind::Vbx
}
#[cfg(not(feature = "vbx"))]
{
ClustererKind::Ahc {
threshold: crate::types::DEFAULT_AHC_THRESHOLD,
}
}
}
fn default_pool_size() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.clamp(1, 4)
}
impl PipelineConfig {
pub(crate) fn validate_settings(&self) -> Result<(), crate::pipeline_v2::ConfigError> {
use crate::pipeline_v2::ConfigError;
let bad =
|field: &'static str, detail: String| ConfigError::InvalidSetting { field, detail };
if !matches!(self.profile, Profile::Custom) && self.sample_rate.get() != 16_000 {
return Err(bad(
"sample_rate",
format!(
"shipping profiles require 16000 Hz (bundled models); got {} Hz. Use Profile::Custom with your own components for another rate",
self.sample_rate.get()
),
));
}
if self.max_speakers == 0 {
return Err(bad("max_speakers", "must be in 1..=255, got 0".into()));
}
if self.min_cluster_size == 0 {
return Err(bad(
"min_cluster_size",
"must be >= 1 (1 disables pruning), got 0".into(),
));
}
if !(self.min_speech_secs.is_finite() && self.min_speech_secs >= 0.0) {
return Err(bad(
"min_speech_secs",
format!("must be finite and >= 0, got {}", self.min_speech_secs),
));
}
if !(self.max_gap_secs.is_finite() && self.max_gap_secs >= 0.0) {
return Err(bad(
"max_gap_secs",
format!("must be finite and >= 0, got {}", self.max_gap_secs),
));
}
if self.embedder_pool_size == 0 {
return Err(bad("embedder_pool_size", "must be >= 1, got 0".into()));
}
if let Some(w) = self.embed_window_secs
&& !(w.is_finite() && w > 0.0)
{
return Err(bad(
"embed_window_secs",
format!("must be finite and > 0 when set, got {w}"),
));
}
if let ClustererKind::Ahc { threshold } = self.clusterer
&& !(-1.0..=1.0).contains(&threshold)
{
return Err(bad(
"clusterer.threshold",
format!("cosine similarity must be in [-1.0, 1.0], got {threshold}"),
));
}
if !self.execution_provider.is_available() {
return Err(bad(
"execution_provider",
format!(
"{:?} is not available; only Cpu is executed (auto resolves to Cpu)",
self.execution_provider
),
));
}
if let Some(bin) = self.experimental.binarization {
if !(bin.onset.is_finite() && (0.0..=1.0).contains(&bin.onset)) {
return Err(bad(
"binarization.onset",
format!("must be finite and in [0.0, 1.0], got {}", bin.onset),
));
}
if !(bin.offset.is_finite() && (0.0..=1.0).contains(&bin.offset)) {
return Err(bad(
"binarization.offset",
format!("must be finite and in [0.0, 1.0], got {}", bin.offset),
));
}
if !(bin.min_duration_on.is_finite() && bin.min_duration_on >= 0.0) {
return Err(bad(
"binarization.min_duration_on",
format!("must be finite and >= 0, got {}", bin.min_duration_on),
));
}
if !(bin.min_duration_off.is_finite() && bin.min_duration_off >= 0.0) {
return Err(bad(
"binarization.min_duration_off",
format!("must be finite and >= 0, got {}", bin.min_duration_off),
));
}
}
if let Some(norm) = &self.as_norm
&& norm.top_n < 2
{
return Err(bad(
"as_norm.top_n",
format!(
"must be >= 2 (1 silently disables normalization), got {}",
norm.top_n
),
));
}
Ok(())
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Profile;
#[test]
fn pipeline_config_default_is_balanced() {
let cfg = PipelineConfig::default();
assert_eq!(cfg.profile, Profile::Balanced);
assert_eq!(cfg.sample_rate.get(), 16000);
#[cfg(feature = "vbx")]
assert_eq!(cfg.clusterer, ClustererKind::Vbx);
#[cfg(not(feature = "vbx"))]
assert!(matches!(
cfg.clusterer,
ClustererKind::Ahc {
threshold
} if (threshold - crate::types::DEFAULT_AHC_THRESHOLD).abs() < f32::EPSILON
));
assert_eq!(cfg.max_speakers, 20);
assert_eq!(cfg.min_cluster_size, 1);
assert!(cfg.resegment_overlap);
assert!(!cfg.experimental.disable_seg_overlap);
assert!(!cfg.experimental.majority_local_map);
assert!((cfg.min_speech_secs - 0.25).abs() < f32::EPSILON);
assert!((cfg.max_gap_secs - 0.5).abs() < f32::EPSILON);
assert!(cfg.embedder_pool_size >= 1);
assert!(cfg.embedder_pool_size <= 4);
assert!(cfg.as_norm.is_none());
assert!(cfg.domain.is_none());
assert!(cfg.experimental.embedder_model.is_none());
assert!(!cfg.experimental.reconstruct);
assert!(cfg.experimental.binarization.is_none());
}
#[test]
fn default_clusterer_matches_front_doors() {
let cfg = PipelineConfig::default();
#[cfg(feature = "vbx")]
assert_eq!(cfg.clusterer, ClustererKind::Vbx);
#[cfg(not(feature = "vbx"))]
match cfg.clusterer {
ClustererKind::Ahc { threshold } => {
assert!((threshold - crate::types::DEFAULT_AHC_THRESHOLD).abs() < f32::EPSILON);
assert!((threshold - Profile::Balanced.default_threshold()).abs() < f32::EPSILON);
}
other => panic!("expected AHC fallback without vbx, got {other:?}"),
}
}
#[test]
fn clusterer_kind_ahc_with_threshold() {
let k = ClustererKind::Ahc { threshold: 0.7 };
if let ClustererKind::Ahc { threshold } = k {
assert!((threshold - 0.7).abs() < f32::EPSILON);
} else {
panic!("expected Ahc variant");
}
}
#[test]
fn execution_provider_auto_returns_some_variant() {
let ep = ExecutionProvider::auto();
let _ = ep;
}
#[test]
fn clusterer_kind_nme_sc_and_vbx_variants_are_distinct() {
assert_eq!(ClustererKind::NmeSc, ClustererKind::NmeSc);
assert_eq!(ClustererKind::Vbx, ClustererKind::Vbx);
assert_ne!(ClustererKind::NmeSc, ClustererKind::Vbx);
assert_ne!(ClustererKind::NmeSc, ClustererKind::Ahc { threshold: 0.5 });
}
#[test]
fn default_pool_size_stays_within_clamp() {
let n = default_pool_size();
assert!((1..=4).contains(&n));
}
}