rtx-csm: WavLM-Base+ SV scaffold for speaker similarity

Phase 5a — architectural skeleton for the microsoft/wavlm-base-plus-sv
reference, drop-in replacement for the SpectralCentroidSimilarity weak
baseline in speaker_sim.rs.

Modules in src/wavlm_sv.rs (~600 LOC):
- FeatureExtractor: 7-layer Conv1d, 320× downsample, GroupNorm at layer
  0 (num_groups=num_channels=512), GELU activations.
- FeatureProjection: LayerNorm + Linear 512→768.
- PosConv: Conv1d(768, 768, k=128, groups=16, pad=64) + GELU; SamePad
  strips trailing frame for even kernel.
- WavLmEncoderLayer: struct shape complete (Q/K/V/out projections, pre-
  attention LN, FFN intermediate/output, final LN, gru_rel_pos_const +
  gru_rel_pos_linear, optional rel_attn_embed at layer 0). forward() is
  a STUB; Phase 5b implements gated rel-pos attention.
- Encoder: 12 stacked layers, returns Vec<Tensor> of 13 hidden states.
- Tdnn: dilated unfold + Linear(in*kernel, out) — matches HF impl.
- XVectorHead: softmax-weighted layer sum + projector 768→512 + 5 TDNN
  layers (kernels [5,3,3,1,1] dilations [1,2,3,1,1]) + statistics pool
  + 3000→512 embedding projection.
- WavLmSv top-level + zero-mean unit-variance normalize + cosine
  similarity helper for verification scoring.

9 shape-correctness tests; 72 lib tests total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-25 19:42:59 -07:00
co-authored by Claude Opus 4.7
parent 63979eab45
commit 90e53b3c0c
2 changed files with 713 additions and 0 deletions
+712
View File
@@ -0,0 +1,712 @@
//! WavLM-Base+ Speaker Verification — Rust port (scaffold).
//!
//! Architectural port of `microsoft/wavlm-base-plus-sv` against candle 0.9.
//! WavLM-Base+ is 12-layer / 768-dim transformer over 16 kHz waveforms;
//! the `-sv` variant adds an X-vector head producing 512-d speaker
//! embeddings. Cosine similarity between embeddings → speaker similarity
//! score (drop-in replacement for the weak `SpectralCentroidSimilarity`
//! in `speaker_sim.rs`).
//!
//! ## Pipeline (input → embedding)
//!
//! ```text
//! waveform [B, T] (16 kHz)
//! │
//! ▼
//! FeatureExtractor (7 conv layers, 320× downsample) → [B, 512, T/320]
//! │
//! transpose ▼
//! FeatureProjection: LN → Linear 512→768 → [B, T/320, 768]
//! │
//! ▼
//! PosConv (Conv1d 768→768, k=128, groups=16, weight_norm) + GELU
//! │
//! add ▼ residual
//! LayerNorm → 12 × WavLMEncoderLayer (with rel_pos_bias) → 13 hidden states
//! │
//! ▼
//! Weighted sum (softmax over 13 layer_weights) → [B, T/320, 768]
//! │
//! ▼
//! X-Vector head:
//! - projector Linear 768→512
//! - 5 TDNN layers with kernels [5,3,3,1,1] dilations [1,2,3,1,1]
//! - ReLU after each
//! - Statistics pooling [mean | std] over time → [B, 3000]
//! - feature_extractor Linear 3000→512 → [B, 512]
//! ```
//!
//! ## Status: PARTIAL SCAFFOLD
//!
//! What this module ships:
//! - All struct definitions for the full pipeline
//! - `FeatureExtractor` (7-layer conv, GroupNorm at layer 0, GELU)
//! - `FeatureProjection` (LayerNorm + Linear 512→768)
//! - `XVectorHead` (5 TDNN + statistics pool + 3000→512 projection)
//! - Stubs for `PosConv`, `WavLMEncoderLayer`, `Encoder`
//! - Shape-correctness tests for the implemented blocks
//!
//! Deferred to Phase 5b:
//! - Bucketed relative-position bias (T5-style, 320 buckets)
//! - Gated relative-position bias (per-layer 1×12×1×1 const + 64→8 linear)
//! - The full `WavLMEncoderLayer` forward (attention + FFN)
//!
//! Deferred to Phase 5c:
//! - PyTorch `pytorch_model.bin` → safetensors conversion (handles weight_norm,
//! `layer_weights` softmax-weights, TDNN `kernel.weight` reshape)
//! - HF Hub asset resolution
//!
//! Deferred to Phase 5d:
//! - Numerical parity vs the HF reference (cosine similarity within 1e-4 on
//! a paired-utterance test set)
use crate::error::{CsmError, Result};
use candle_core::{DType, Device, IndexOp, Module, Tensor, D};
use candle_nn::{
conv1d, group_norm, layer_norm, linear, ops, Activation, Conv1d, Conv1dConfig, GroupNorm,
LayerNorm, LayerNormConfig, Linear, VarBuilder,
};
use std::path::Path;
pub const SAMPLE_RATE: u32 = 16_000;
pub const HIDDEN_DIM: usize = 768;
pub const NUM_HEADS: usize = 12;
pub const HEAD_DIM: usize = HIDDEN_DIM / NUM_HEADS; // 64
pub const FFN_DIM: usize = 3072;
pub const NUM_LAYERS: usize = 12;
pub const FEATURE_DIM: usize = 512;
pub const HOP_LENGTH: usize = 320; // total CNN downsampling
pub const REL_NUM_BUCKETS: usize = 320;
pub const REL_MAX_DISTANCE: usize = 800;
pub const EMBEDDING_DIM: usize = 512;
pub const STAT_POOL_DIM: usize = 3000; // 1500 * 2 (mean+std)
// -- 1. Feature extractor (7-layer Conv1d, 320× downsampling) --------------
#[derive(Debug, Clone)]
struct ConvLayer {
conv: Conv1d,
norm: Option<GroupNorm>,
#[allow(dead_code)]
kernel: usize,
#[allow(dead_code)]
stride: usize,
}
impl ConvLayer {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let h = xs.apply(&self.conv)?;
let h = match self.norm.as_ref() {
Some(gn) => h.apply(gn)?,
None => h,
};
h.gelu()
}
}
#[derive(Debug, Clone)]
pub struct FeatureExtractor {
layers: Vec<ConvLayer>,
}
impl FeatureExtractor {
/// vb is rooted at `wavlm.feature_extractor`. Each layer's path is
/// `conv_layers.{i}.conv.weight`. Layer 0 also has
/// `conv_layers.0.layer_norm.{weight,bias}` (WavLM uses GroupNorm with
/// num_groups = num_channels = 512, despite the parameter name).
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
// (out_channels, kernel, stride). Input is mono so first in=1.
let specs: [(usize, usize, usize); 7] = [
(FEATURE_DIM, 10, 5),
(FEATURE_DIM, 3, 2),
(FEATURE_DIM, 3, 2),
(FEATURE_DIM, 3, 2),
(FEATURE_DIM, 3, 2),
(FEATURE_DIM, 2, 2),
(FEATURE_DIM, 2, 2),
];
let mut layers = Vec::with_capacity(7);
let mut in_ch = 1usize;
let vb_layers = vb.pp("conv_layers");
for (i, (out_ch, k, s)) in specs.iter().enumerate() {
let cfg = Conv1dConfig {
stride: *s,
..Default::default()
};
let conv = candle_nn::conv1d_no_bias(in_ch, *out_ch, *k, cfg, vb_layers.pp(i.to_string()).pp("conv"))?;
let norm = if i == 0 {
// WavLM/wav2vec2 GroupNorm layer 0: num_groups = num_channels.
Some(group_norm(*out_ch, *out_ch, 1e-5, vb_layers.pp(i.to_string()).pp("layer_norm"))?)
} else {
None
};
layers.push(ConvLayer {
conv,
norm,
kernel: *k,
stride: *s,
});
in_ch = *out_ch;
}
Ok(Self { layers })
}
}
impl Module for FeatureExtractor {
/// Input `(B, 1, T)`; output `(B, FEATURE_DIM=512, T/320)`.
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let mut h = xs.clone();
for layer in &self.layers {
h = layer.forward(&h)?;
}
Ok(h)
}
}
// -- 2. Feature projection (LN + Linear 512 → 768) ------------------------
#[derive(Debug, Clone)]
pub struct FeatureProjection {
layer_norm: LayerNorm,
projection: Linear,
}
impl FeatureProjection {
/// vb is rooted at `wavlm.feature_projection`.
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let layer_norm = layer_norm(
FEATURE_DIM,
LayerNormConfig {
eps: 1e-5,
..Default::default()
},
vb.pp("layer_norm"),
)?;
let projection = linear(FEATURE_DIM, HIDDEN_DIM, vb.pp("projection"))?;
Ok(Self {
layer_norm,
projection,
})
}
}
impl Module for FeatureProjection {
/// Input `(B, T/320, 512)`; output `(B, T/320, 768)`.
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
xs.apply(&self.layer_norm)?.apply(&self.projection)
}
}
// -- 3. Positional convolution (depthwise Conv1d with weight_norm) --------
/// `Conv1d(768, 768, kernel=128, padding=64, groups=16)` with `weight_norm`.
/// Output is GELU-activated and added to the input as a residual.
///
/// Note: weight_norm storage requires a converter pass (g/v → weight),
/// matching the AudioSeal converter pattern. Currently builds a plain
/// Conv1d; safetensors loader must merge before reading.
#[derive(Debug, Clone)]
pub struct PosConv {
conv: Conv1d,
kernel: usize,
}
impl PosConv {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let cfg = Conv1dConfig {
padding: 64,
groups: 16,
..Default::default()
};
let conv = conv1d(HIDDEN_DIM, HIDDEN_DIM, 128, cfg, vb.pp("pos_conv_embed.conv"))?;
Ok(Self { conv, kernel: 128 })
}
}
impl Module for PosConv {
/// Input/output: `(B, T/320, 768)` (treats time axis -2 as conv axis).
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// (B, T, C) → (B, C, T) → conv → (B, C, T) → drop trailing pad → (B, T, C)
let in_len = xs.dim(D::Minus2)?;
let xs_bct = xs.transpose(1, 2)?.contiguous()?;
let h = xs_bct.apply(&self.conv)?;
// SamePad strips 1 trailing frame because kernel=128 is even.
let out_len = h.dim(D::Minus1)?;
let h = if self.kernel % 2 == 0 && out_len > in_len {
h.narrow(D::Minus1, 0, in_len)?
} else {
h
};
let h = h.gelu()?;
// Back to (B, T, C).
h.transpose(1, 2)?.contiguous()
}
}
// -- 4. Transformer encoder layer (STUB — Phase 5b) -----------------------
/// One WavLM encoder layer. Post-norm: residual+attn → LN → FFN-residual → final-LN.
///
/// **Stub**: forward returns input unchanged. Phase 5b will implement
/// gated relative-position bias attention + 768→3072→768 FFN.
#[derive(Debug, Clone)]
pub struct WavLmEncoderLayer {
// Phase 5b — these will be populated:
pub q_proj: Linear,
pub k_proj: Linear,
pub v_proj: Linear,
pub out_proj: Linear,
pub attn_norm: LayerNorm,
pub fc1: Linear,
pub fc2: Linear,
pub final_norm: LayerNorm,
/// Per-layer gated rel-pos bias parameters (1, 12, 1, 1).
pub gru_rel_pos_const: Tensor,
/// Linear from head_dim=64 → 8 for the 2-gate split.
pub gru_rel_pos_linear: Linear,
/// Only set for layer 0 — bucketed embedding (320, 12).
pub rel_attn_embed: Option<candle_nn::Embedding>,
}
impl WavLmEncoderLayer {
pub fn new(layer_idx: usize, vb: VarBuilder) -> candle_core::Result<Self> {
let attn = vb.pp("attention");
let q_proj = linear(HIDDEN_DIM, HIDDEN_DIM, attn.pp("q_proj"))?;
let k_proj = linear(HIDDEN_DIM, HIDDEN_DIM, attn.pp("k_proj"))?;
let v_proj = linear(HIDDEN_DIM, HIDDEN_DIM, attn.pp("v_proj"))?;
let out_proj = linear(HIDDEN_DIM, HIDDEN_DIM, attn.pp("out_proj"))?;
let attn_norm = layer_norm(HIDDEN_DIM, 1e-5, vb.pp("layer_norm"))?;
let ff = vb.pp("feed_forward");
let fc1 = linear(HIDDEN_DIM, FFN_DIM, ff.pp("intermediate_dense"))?;
let fc2 = linear(FFN_DIM, HIDDEN_DIM, ff.pp("output_dense"))?;
let final_norm = layer_norm(HIDDEN_DIM, 1e-5, vb.pp("final_layer_norm"))?;
let gru_rel_pos_const = attn
.pp("gru_rel_pos_const")
.get((1, NUM_HEADS, 1, 1), "")
.or_else(|_| {
Tensor::zeros((1, NUM_HEADS, 1, 1), vb.dtype(), vb.device())
})?;
let gru_rel_pos_linear = linear(HEAD_DIM, 8, attn.pp("gru_rel_pos_linear"))?;
let rel_attn_embed = if layer_idx == 0 {
Some(candle_nn::embedding(
REL_NUM_BUCKETS,
NUM_HEADS,
attn.pp("rel_attn_embed"),
)?)
} else {
None
};
Ok(Self {
q_proj,
k_proj,
v_proj,
out_proj,
attn_norm,
fc1,
fc2,
final_norm,
gru_rel_pos_const,
gru_rel_pos_linear,
rel_attn_embed,
})
}
/// **STUB**: returns input unchanged. Phase 5b implements full
/// attention + FFN. Shape `(B, T, 768)` in/out.
pub fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
Ok(xs.clone())
}
}
// -- 5. Encoder (12 stacked layers, returns 13 hidden states) -------------
#[derive(Debug, Clone)]
pub struct Encoder {
pos_conv: PosConv,
layer_norm: LayerNorm,
layers: Vec<WavLmEncoderLayer>,
}
impl Encoder {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let pos_conv = PosConv::new(vb.clone())?;
let layer_norm = layer_norm(HIDDEN_DIM, 1e-5, vb.pp("layer_norm"))?;
let mut layers = Vec::with_capacity(NUM_LAYERS);
let vb_layers = vb.pp("layers");
for i in 0..NUM_LAYERS {
layers.push(WavLmEncoderLayer::new(i, vb_layers.pp(i.to_string()))?);
}
Ok(Self {
pos_conv,
layer_norm,
layers,
})
}
/// Input `(B, T, 768)`; output is `(B, T, 768)` per layer + initial,
/// returned as a `Vec<Tensor>` of length `NUM_LAYERS + 1 = 13`.
pub fn forward_all_layers(&self, xs: &Tensor) -> candle_core::Result<Vec<Tensor>> {
let pos = self.pos_conv.forward(xs)?;
let mut h = (xs + &pos)?;
h = h.apply(&self.layer_norm)?;
let mut hidden_states = Vec::with_capacity(NUM_LAYERS + 1);
hidden_states.push(h.clone());
for layer in &self.layers {
h = layer.forward(&h)?;
hidden_states.push(h.clone());
}
Ok(hidden_states)
}
}
// -- 6. X-Vector head (TDNN + stats pool + projection) --------------------
/// One TDNN layer: implemented per the HF reference as `Linear(in*kernel, out)`
/// fed via a manually-strided unfold over the time axis with `dilation`.
#[derive(Debug, Clone)]
pub struct Tdnn {
kernel_linear: Linear,
#[allow(dead_code)]
in_dim: usize,
#[allow(dead_code)]
out_dim: usize,
kernel: usize,
dilation: usize,
}
impl Tdnn {
pub fn new(
in_dim: usize,
out_dim: usize,
kernel: usize,
dilation: usize,
vb: VarBuilder,
) -> candle_core::Result<Self> {
let kernel_linear = linear(in_dim * kernel, out_dim, vb.pp("kernel"))?;
Ok(Self {
kernel_linear,
in_dim,
out_dim,
kernel,
dilation,
})
}
}
impl Module for Tdnn {
/// Input `(B, T, in_dim)`; output `(B, T_out, out_dim)` where
/// `T_out = T - (kernel - 1) * dilation` (no padding, valid-only).
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let (b, t, c) = xs.dims3()?;
let span = (self.kernel - 1) * self.dilation;
if t <= span {
return Err(candle_core::Error::Msg(format!(
"TDNN input length {t} too short for kernel {} dilation {}",
self.kernel, self.dilation
)));
}
let t_out = t - span;
// Build a (B, T_out, kernel*in_dim) tensor by gathering kernel
// dilated samples per output frame.
let mut frames: Vec<Tensor> = Vec::with_capacity(self.kernel);
for k in 0..self.kernel {
let offset = k * self.dilation;
// (B, T_out, in_dim)
let slice = xs.narrow(1, offset, t_out)?;
frames.push(slice);
}
let stacked = Tensor::cat(&frames, 2)?; // (B, T_out, kernel * in_dim)
debug_assert_eq!(stacked.dims(), &[b, t_out, self.kernel * c]);
// Linear → (B, T_out, out_dim) → ReLU
stacked.apply(&self.kernel_linear)?.relu()
}
}
#[derive(Debug, Clone)]
pub struct XVectorHead {
/// Softmax over `layer_weights` of length `NUM_LAYERS + 1 = 13`.
layer_weights: Tensor,
projector: Linear,
tdnn: Vec<Tdnn>,
/// Linear 3000 → 512 — the "feature_extractor" key in the HF state_dict.
/// We rename to avoid collision with the WavLM CNN feature extractor.
embedding_proj: Linear,
}
impl XVectorHead {
/// vb is rooted at the *top* of the WavLMForXVector state_dict (so we
/// read `layer_weights`, `projector`, `tdnn.{0..4}`, `feature_extractor`).
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let layer_weights = vb.pp("layer_weights").get(NUM_LAYERS + 1, "")?;
let projector = linear(HIDDEN_DIM, FEATURE_DIM, vb.pp("projector"))?;
// TDNN specs: (in, out, kernel, dilation)
let tdnn_specs: [(usize, usize, usize, usize); 5] = [
(FEATURE_DIM, 512, 5, 1),
(512, 512, 3, 2),
(512, 512, 3, 3),
(512, 512, 1, 1),
(512, 1500, 1, 1),
];
let mut tdnn = Vec::with_capacity(5);
let vb_tdnn = vb.pp("tdnn");
for (i, (in_dim, out_dim, k, d)) in tdnn_specs.iter().enumerate() {
tdnn.push(Tdnn::new(*in_dim, *out_dim, *k, *d, vb_tdnn.pp(i.to_string()))?);
}
let embedding_proj = linear(STAT_POOL_DIM, EMBEDDING_DIM, vb.pp("feature_extractor"))?;
Ok(Self {
layer_weights,
projector,
tdnn,
embedding_proj,
})
}
/// Input: `Vec<Tensor>` of length 13, each `(B, T, 768)`. Output: `(B, 512)`.
pub fn forward(&self, hidden_states: &[Tensor]) -> candle_core::Result<Tensor> {
if hidden_states.len() != NUM_LAYERS + 1 {
return Err(candle_core::Error::Msg(format!(
"expected {} hidden states, got {}",
NUM_LAYERS + 1,
hidden_states.len()
)));
}
// Softmax-weighted sum over layers.
let weights = ops::softmax(&self.layer_weights, 0)?; // (13,)
let weights = weights.to_dtype(hidden_states[0].dtype())?;
let mut sum: Option<Tensor> = None;
for (i, h) in hidden_states.iter().enumerate() {
let w = weights.i(i)?; // scalar
let scaled = h.broadcast_mul(&w.reshape((1, 1, 1))?)?;
sum = Some(match sum {
Some(prev) => (prev + scaled)?,
None => scaled,
});
}
let h = sum.expect("at least one hidden state");
// Projector 768 → 512.
let mut h = h.apply(&self.projector)?;
// 5 TDNN layers (reduce time dim each time).
for layer in &self.tdnn {
h = layer.forward(&h)?;
}
// Statistics pooling: mean & std over time axis (dim=1).
let mean = h.mean(1)?; // (B, 1500)
let var = h.var(1)?;
let std = (var + 1e-9)?.sqrt()?;
let stat = Tensor::cat(&[&mean, &std], D::Minus1)?; // (B, 3000)
// Final projection 3000 → 512.
stat.apply(&self.embedding_proj)
}
}
// -- 7. Top-level model ----------------------------------------------------
#[derive(Debug, Clone)]
pub struct WavLmSv {
feature_extractor: FeatureExtractor,
feature_projection: FeatureProjection,
encoder: Encoder,
head: XVectorHead,
}
impl WavLmSv {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let backbone = vb.pp("wavlm");
let feature_extractor = FeatureExtractor::new(backbone.pp("feature_extractor"))?;
let feature_projection = FeatureProjection::new(backbone.pp("feature_projection"))?;
let encoder = Encoder::new(backbone.pp("encoder"))?;
let head = XVectorHead::new(vb.clone())?;
Ok(Self {
feature_extractor,
feature_projection,
encoder,
head,
})
}
/// Per-utterance zero-mean/unit-variance normalization (matches
/// HF Wav2Vec2FeatureExtractor with do_normalize=True).
pub fn normalize_waveform(samples: &[f32]) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let n = samples.len() as f32;
let mean = samples.iter().sum::<f32>() / n;
let var = samples.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / n;
let std = (var + 1e-7).sqrt();
samples.iter().map(|s| (s - mean) / std).collect()
}
/// Compute a `(B, 512)` speaker embedding from a 16 kHz mono waveform
/// tensor `(B, 1, T)`. Caller is responsible for normalization.
pub fn embed(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let features = self.feature_extractor.forward(xs)?; // (B, 512, T/320)
// (B, 512, T') → (B, T', 512)
let features = features.transpose(1, 2)?.contiguous()?;
let projected = self.feature_projection.forward(&features)?; // (B, T', 768)
let hidden_states = self.encoder.forward_all_layers(&projected)?; // 13 × (B, T', 768)
self.head.forward(&hidden_states)
}
/// Convenience: embed a `Vec<f32>` and return the embedding as a Vec<f32>.
pub fn embed_samples(&self, samples: &[f32], device: &Device) -> Result<Vec<f32>> {
let normalized = Self::normalize_waveform(samples);
let xs = Tensor::from_slice(&normalized, (1, 1, normalized.len()), device)
.map_err(|e| CsmError::Config(format!("embed: tensor: {e}")))?;
let emb = self
.embed(&xs)
.map_err(|e| CsmError::Config(format!("embed: forward: {e}")))?;
emb.i(0)?
.to_dtype(DType::F32)
.and_then(|t| t.to_vec1::<f32>())
.map_err(|e| CsmError::Config(format!("embed: to_vec: {e}")))
}
/// Cosine similarity between two embeddings, both expected as `(D,)`
/// or `(B, D)` tensors of compatible shape.
pub fn cosine_similarity(a: &Tensor, b: &Tensor) -> candle_core::Result<Tensor> {
let a_norm = a.broadcast_div(
&(a.sqr()?.sum_keepdim(D::Minus1)? + 1e-9)?
.sqrt()?,
)?;
let b_norm = b.broadcast_div(
&(b.sqr()?.sum_keepdim(D::Minus1)? + 1e-9)?
.sqrt()?,
)?;
(a_norm * b_norm)?.sum(D::Minus1)
}
}
/// Stub loader. Phase 5c will implement `pytorch_model.bin` → safetensors
/// conversion (weight_norm merge for pos_conv + TDNN kernel reshape) and
/// HF Hub asset resolution under `microsoft/wavlm-base-plus-sv`.
pub fn load_from_safetensors<P: AsRef<Path>>(
safetensors: P,
device: &Device,
) -> Result<WavLmSv> {
let vb = unsafe {
candle_nn::VarBuilder::from_mmaped_safetensors(
&[safetensors.as_ref()],
DType::F32,
device,
)
}
.map_err(|e| CsmError::Config(format!("opening WavLM safetensors: {e}")))?;
WavLmSv::new(vb).map_err(|e| CsmError::Config(format!("WavLmSv::new: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use candle_nn::VarMap;
fn random_vb(device: &Device) -> (VarMap, VarBuilder<'static>) {
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, device);
(vm, vb)
}
#[test]
fn feature_extractor_downsamples_by_320() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let fe = FeatureExtractor::new(vb).unwrap();
// 16000 samples = 1s @ 16 kHz → ~50 frames.
let xs = Tensor::randn(0f32, 1f32, (1, 1, 16000), &device).unwrap();
let out = fe.forward(&xs).unwrap();
let frames = out.dim(D::Minus1).unwrap();
assert_eq!(out.dim(0).unwrap(), 1);
assert_eq!(out.dim(1).unwrap(), FEATURE_DIM);
assert!(
(frames as i64 - 49).abs() <= 2,
"expected ~49 frames, got {frames}"
);
}
#[test]
fn feature_projection_lifts_512_to_768() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let fp = FeatureProjection::new(vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (2, 49, FEATURE_DIM), &device).unwrap();
let out = fp.forward(&xs).unwrap();
assert_eq!(out.dims(), &[2, 49, HIDDEN_DIM]);
}
#[test]
fn pos_conv_preserves_shape() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let pc = PosConv::new(vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 49, HIDDEN_DIM), &device).unwrap();
let out = pc.forward(&xs).unwrap();
assert_eq!(out.dims(), &[1, 49, HIDDEN_DIM]);
}
#[test]
fn tdnn_reduces_time_axis() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
// kernel=5, dilation=1 → T_out = T - 4
let t = Tdnn::new(FEATURE_DIM, 512, 5, 1, vb.clone()).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 49, FEATURE_DIM), &device).unwrap();
let out = t.forward(&xs).unwrap();
assert_eq!(out.dims(), &[1, 45, 512]);
}
#[test]
fn tdnn_dilated_reduces_correctly() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
// kernel=3, dilation=2 → span = 4, T_out = T - 4
let t = Tdnn::new(512, 512, 3, 2, vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 30, 512), &device).unwrap();
let out = t.forward(&xs).unwrap();
assert_eq!(out.dims(), &[1, 26, 512]);
}
#[test]
fn xvector_head_produces_512d_embedding() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let head = XVectorHead::new(vb).unwrap();
// T must be at least 5+4+4 = 13 frames after the TDNN cascade.
// Cascade reductions: 4, 4, 4, 0, 0 = 12 frames lost. Need T >= 13.
let t = 49usize;
let hidden_states: Vec<Tensor> = (0..NUM_LAYERS + 1)
.map(|_| Tensor::randn(0f32, 1f32, (1, t, HIDDEN_DIM), &device).unwrap())
.collect();
let emb = head.forward(&hidden_states).unwrap();
assert_eq!(emb.dims(), &[1, EMBEDDING_DIM]);
}
#[test]
fn end_to_end_forward_smoke() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let model = WavLmSv::new(vb).unwrap();
// 1s @ 16 kHz = 16000 samples.
let xs = Tensor::randn(0f32, 1f32, (1, 1, 16000), &device).unwrap();
let emb = model.embed(&xs).unwrap();
assert_eq!(emb.dims(), &[1, EMBEDDING_DIM]);
}
#[test]
fn cosine_similarity_self_is_one() {
let device = Device::Cpu;
let v = Tensor::from_slice(&[1.0f32, 2.0, 3.0, 4.0], (4,), &device).unwrap();
let s = WavLmSv::cosine_similarity(&v, &v).unwrap();
let v: f32 = s.to_dtype(DType::F32).unwrap().to_scalar().unwrap();
assert!((v - 1.0).abs() < 1e-5, "expected 1.0, got {v}");
}
#[test]
fn normalize_waveform_zero_mean() {
let s = WavLmSv::normalize_waveform(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mean: f32 = s.iter().sum::<f32>() / s.len() as f32;
assert!(mean.abs() < 1e-5);
let var = s.iter().map(|x| x * x).sum::<f32>() / s.len() as f32;
assert!((var - 1.0).abs() < 1e-3, "expected unit variance, got {var}");
}
}