use crate::clusterer::Clusterer;
use crate::models::{ModelRegistry, RegistryError};
use crate::pipeline_v2::builder::ConfigError;
use crate::pipeline_v2::config::{ClustererKind, PipelineConfig};
pub(crate) fn resolve_clusterer_kind(config: &PipelineConfig) -> ClustererKind {
match (config.clusterer, config.domain) {
(ClustererKind::Ahc { threshold }, Some(domain)) => {
let threshold = if config.as_norm.is_some() {
domain.as_norm_threshold.unwrap_or(threshold)
} else {
domain.ahc_threshold
};
ClustererKind::Ahc { threshold }
}
(kind, _) => kind,
}
}
pub(crate) fn load_as_norm_cohort(
as_norm: &crate::clusterer::AsNormConfig,
registry: &ModelRegistry,
) -> Result<crate::clusterer::AsNormCohort, ConfigError> {
use crate::clusterer::CohortSource;
let path = match &as_norm.cohort {
CohortSource::Path(p) => p.clone(),
CohortSource::ModelId(id) => match std::env::var_os("POLYVOICE_ASNORM_COHORT") {
Some(p) => std::path::PathBuf::from(p),
None => registry.ensure(id).map_err(|e| match e {
RegistryError::ModelNotFound { .. } => ConfigError::Load {
model_id: "asnorm_cohort",
source: std::io::Error::other(format!(
"cohort model '{id}' is not in the manifest; pass an explicit cohort \
file (CLI: --cohort) or set POLYVOICE_ASNORM_COHORT"
))
.into(),
},
other => ConfigError::Registry(other),
})?,
},
};
crate::clusterer::AsNormCohort::from_npy(&path).map_err(|e| ConfigError::Load {
model_id: "asnorm_cohort",
source: Box::new(e),
})
}
pub(crate) fn build_profile_clusterer(
config: &PipelineConfig,
registry: &ModelRegistry,
) -> Result<Box<dyn Clusterer>, ConfigError> {
match resolve_clusterer_kind(config) {
ClustererKind::Ahc { threshold } => {
let max = config.max_speakers as usize;
match &config.as_norm {
Some(as_norm) => {
let cohort = load_as_norm_cohort(as_norm, registry)?;
Ok(Box::new(crate::clusterer::AsNormClusterer::new(
max,
threshold,
cohort,
as_norm.top_n,
)))
}
None => Ok(Box::new(crate::clusterer::AhcClusterer::with_threshold(
max, threshold,
))),
}
}
#[cfg(feature = "spectral")]
ClustererKind::NmeSc => Ok(Box::new(crate::clusterer::NmeScClusterer::new(
config.max_speakers as usize,
))),
#[cfg(not(feature = "spectral"))]
ClustererKind::NmeSc => Err(ConfigError::UnknownModel {
model_id: "nme-sc (requires the `spectral` feature)".to_owned(),
}),
#[cfg(feature = "vbx")]
ClustererKind::Vbx => {
let max = config.max_speakers as usize;
let vbx_cfg = vbx_config_for_pipeline();
let mut vbx = match &config.vbx_plda_dir {
Some(dir) => {
crate::clusterer::vbx::VbxClusterer::from_dir_with_config(dir, max, vbx_cfg)
}
None => match std::env::var_os("POLYVOICE_VBX_PLDA_DIR") {
Some(dir) => crate::clusterer::vbx::VbxClusterer::from_dir_with_config(
std::path::Path::new(&dir),
max,
vbx_cfg,
),
None => {
#[cfg(feature = "download")]
{
if registry.allows_download() {
crate::clusterer::vbx::VbxClusterer::from_registry_with_config(
registry, max, vbx_cfg,
)
} else {
Err(crate::clusterer::ClustererError::AlgorithmFailed {
detail: "vbx_plda_dir is required for a local model directory (this registry does not download)".into(),
})
}
}
#[cfg(not(feature = "download"))]
{
let _ = registry;
Err(crate::clusterer::ClustererError::AlgorithmFailed {
detail: "vbx_plda_dir is required when the download feature is off"
.into(),
})
}
}
},
}
.map_err(|e| ConfigError::Load {
model_id: "vbx",
source: Box::new(e),
})?;
let windowed = config.embed_window_secs.is_some_and(|w| w > 0.0);
vbx = vbx.auto_gmm_for_windowed(windowed);
if config.experimental.reconstruct && !vbx_from_env_enabled() {
vbx = vbx.with_min_embedding_secs(5.0);
}
Ok(Box::new(vbx))
}
#[cfg(not(feature = "vbx"))]
ClustererKind::Vbx => Err(ConfigError::UnknownModel {
model_id: "vbx (requires the `vbx` feature)".to_owned(),
}),
}
}
#[cfg(feature = "vbx")]
pub(crate) fn vbx_from_env_enabled() -> bool {
matches!(
std::env::var("POLYVOICE_VBX_FROM_ENV"),
Ok(v) if v == "1" || v.eq_ignore_ascii_case("true")
)
}
#[cfg(not(feature = "vbx"))]
pub(crate) fn vbx_from_env_enabled() -> bool {
false
}
fn env_flag(name: &str) -> bool {
matches!(
std::env::var(name),
Ok(s) if s == "1" || s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("yes")
)
}
pub(crate) fn scoring_chain_mask_env() -> (Option<f64>, bool) {
if !vbx_from_env_enabled() {
return (None, false);
}
let fallback = std::env::var("POLYVOICE_VBX_CLEAN_FALLBACK_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
(
env_flag("POLYVOICE_VBX_CLEAN_MASK").then_some(fallback),
env_flag("POLYVOICE_VBX_FILTER_CLEAN"),
)
}
#[cfg(feature = "vbx")]
fn vbx_config_for_pipeline() -> crate::clusterer::vbx::VbxClustererConfig {
if vbx_from_env_enabled() {
crate::clusterer::vbx::VbxClustererConfig::from_env()
} else {
crate::clusterer::vbx::VbxClustererConfig::default()
}
}
#[allow(clippy::unwrap_used)]
#[cfg(all(test, not(feature = "spectral")))]
mod tests {
use super::*;
use crate::pipeline_v2::config::{ClustererKind, PipelineConfig};
#[test]
fn nme_sc_without_spectral_is_an_error() {
let tmp = tempfile::TempDir::new().unwrap();
let registry = ModelRegistry::with_cache_dir(tmp.path()).unwrap();
let cfg = PipelineConfig {
clusterer: ClustererKind::NmeSc,
..PipelineConfig::default()
};
let err = match build_profile_clusterer(&cfg, ®istry) {
Err(e) => e,
Ok(_) => panic!("NME-SC without spectral must fail"),
};
match err {
ConfigError::UnknownModel { model_id } => {
assert!(
model_id.contains("spectral"),
"error should name the feature, got {model_id}"
);
}
other => panic!("expected UnknownModel, got {other:?}"),
}
}
}
#[allow(clippy::unwrap_used)]
#[cfg(all(test, feature = "vbx"))]
mod vbx_env_tests {
use super::vbx_config_for_pipeline;
use crate::clusterer::vbx::VbxClustererConfig;
use crate::test_env::EnvGuard;
fn clear_vbx_env(env: &mut EnvGuard) {
for k in [
"POLYVOICE_VBX_FROM_ENV",
"POLYVOICE_VBX_FA",
"POLYVOICE_VBX_FB",
"POLYVOICE_VBX_LOOP_PROB",
"POLYVOICE_VBX_AHC_THRESHOLD",
"POLYVOICE_VBX_EMB_SCALE",
"POLYVOICE_VBX_MIN_EMB_SECS",
"POLYVOICE_VBX_AHC_ASC_MEMBERS",
"POLYVOICE_VBX_AHC_RAW_L2",
"POLYVOICE_VBX_SOFT_REASSIGN",
"POLYVOICE_VBX_CLEAN_MASK",
"POLYVOICE_VBX_FILTER_CLEAN",
"POLYVOICE_VBX_CLEAN_FALLBACK_SECS",
] {
env.remove(k);
}
}
#[test]
fn from_env_gate_is_required_to_overlay_knobs() {
let mut env = crate::test_env::lock();
clear_vbx_env(&mut env);
env.set("POLYVOICE_VBX_FA", "0.99");
let c = vbx_config_for_pipeline();
let d = VbxClustererConfig::default();
assert!(
(c.vbx.fa - d.vbx.fa).abs() < 1e-12,
"FA overlay must require POLYVOICE_VBX_FROM_ENV"
);
env.set("POLYVOICE_VBX_FROM_ENV", "1");
env.set("POLYVOICE_VBX_FA", "0.42");
let c = vbx_config_for_pipeline();
assert!((c.vbx.fa - 0.42).abs() < 1e-12);
clear_vbx_env(&mut env);
env.set("POLYVOICE_VBX_FROM_ENV", "true");
env.set("POLYVOICE_VBX_EMB_SCALE", "3.5");
let c = vbx_config_for_pipeline();
assert!((c.emb_scale - 3.5).abs() < 1e-6);
}
#[test]
fn scoring_chain_mask_env_requires_from_env_gate() {
let mut env = crate::test_env::lock();
clear_vbx_env(&mut env);
env.set("POLYVOICE_VBX_CLEAN_MASK", "1");
env.set("POLYVOICE_VBX_FILTER_CLEAN", "1");
let (mask, filter) = super::scoring_chain_mask_env();
assert!(mask.is_none(), "mask overlay must require FROM_ENV");
assert!(!filter, "filter overlay must require FROM_ENV");
env.set("POLYVOICE_VBX_FROM_ENV", "1");
let (mask, filter) = super::scoring_chain_mask_env();
assert_eq!(mask, Some(2.0));
assert!(filter);
}
}