#!/usr/bin/env python """inference.py — Pi-Indic-Speaker-Embed-v1: Extract 512-dim speaker embeddings. A clean, self-contained inference script for the Pi-Indic-Speaker-Embed-v1 model. Downloads WavLM Large from HuggingFace at runtime; only the lightweight trainable weights (LoRA + ECAPA-TDNN + pooling + projector, ~96 MB) are stored in the checkpoint. Usage as a library: from inference import PiIndicSpeakerEmbed model = PiIndicSpeakerEmbed.from_pretrained("Paytmlabs/Pi-indic-speaker-embed-v1") embedding = model.extract("audio.wav") # → (512,) numpy array embeddings = model.extract_batch(["a.wav", "b.wav"]) # → (N, 512) numpy array Usage from command line: python inference.py --audio audio1.wav audio2.wav --output embeddings.npy Architecture: WavLM Large (frozen base + LoRA r=8/16 on layers 9-24) → multilayer weighted sum (1024-dim frame features) → utterance mean normalization → ECAPA-TDNN (3 Res2Net blocks, 1536-dim frame embeddings) → Channel-Attention Statistics Pooling (3072-dim utterance embedding) → RawNet3 Projector (BatchNorm + Linear → 512-dim speaker embedding) → L2 normalize Requirements: pip install espnet s3prl transformers torch soundfile librosa numpy """ import os import sys import argparse import numpy as np import torch import torch.nn.functional as F import soundfile as sf from pathlib import Path from typing import Union, List, Optional class PiIndicSpeakerEmbed: """Pi-Indic-Speaker-Embed-v1: 512-dim speaker embedding extractor for Indic languages. The model is warm-started from espnet/voxcelebs12_ecapa_wavlm_joint and fine-tuned on Kathbath (12 Indic languages) + VoxCeleb with LoRA on WavLM layers 9-24 and full ECAPA-TDNN fine-tuning. Achieves 1.586% EER on Kathbath validation (unseen speakers) and 4.003% EER on IndicVoices-R test set (Hindi, Marathi, Bengali, Telugu). """ def __init__(self, model_path: str, config_path: str, device: str = "auto", target_duration: float = 6.0, num_eval: int = 3, batch_size: int = 10): """Initialize the model. Args: model_path: Path to the stripped checkpoint (.pth file) config_path: Path to the config YAML file device: "cuda", "cpu", or "auto" (uses CUDA if available) target_duration: Audio chunk length in seconds (default 6.0) num_eval: Number of random crops per utterance for score averaging (default 3) batch_size: Batch size for forward pass (default 10) """ self.target_duration = target_duration self.num_eval = num_eval self.batch_size = batch_size if device == "auto": self.device = "cuda" if torch.cuda.is_available() else "cpu" else: self.device = device self._build_model(config_path) self._load_checkpoint(model_path) self.model.eval() def _build_model(self, config_path: str): """Build the ESPnet speaker model from config, with LoRA injection.""" # Register custom frontend + preprocessor from espnet2.tasks.spk import SpeakerTask, frontend_choices, preprocessor_choices _register_custom_components() import yaml with open(config_path) as f: config = yaml.safe_load(f) self.spk_num = config.get("_spk_num", 3790) # doesn't affect embedding extraction model_args = SpeakerTask.get_parser().parse_args([ "--config", config_path, "--output_dir", "/tmp/pi_indic_dummy", "--ngpu", "1" if "cuda" in self.device else "0", "--train_data_path_and_name_and_type", "data/train/wav.scp,speech,sound", "--train_data_path_and_name_and_type", "data/train/utt2spk,spk_labels,text", "--valid_data_path_and_name_and_type", "data/eval/trial.scp,speech,sound", "--valid_data_path_and_name_and_type", "data/eval/trial2.scp,speech2,sound", "--valid_data_path_and_name_and_type", "data/eval/trial_label,spk_labels,text", "--spk2utt", "data/train/spk2utt", "--spk_num", str(self.spk_num), "--train_shape_file", "exp/spk_train_ecapa_wavlm_lora_512/spk_stats/train/speech_shape", "--valid_shape_file", "exp/spk_train_ecapa_wavlm_lora_512/spk_stats/valid/speech_shape", ]) print("Building model (WavLM Large + LoRA + ECAPA-TDNN)...") self.model = SpeakerTask.build_model(model_args).to(self.device) # Build preprocessor from spk_preprocessor_lora import SpkLoRAPreprocessor pp_conf = config.get("preprocessor_conf", {}) self.preprocessor = SpkLoRAPreprocessor( train=False, target_duration=pp_conf.get("target_duration", self.target_duration), sample_rate=pp_conf.get("sample_rate", 16000), num_eval=pp_conf.get("num_eval", self.num_eval), noise_apply_prob=0.0, rir_apply_prob=0.0, noise_info=[], rir_scp=None, ) def _load_checkpoint(self, model_path: str): """Load the stripped checkpoint (LoRA + ECAPA + pooling + projector only). WavLM base weights are NOT in the checkpoint — they're downloaded from HuggingFace (microsoft/wavlm-large) during model building. Only the LoRA adapters and downstream components are loaded here. """ print(f"Loading checkpoint: {os.path.basename(model_path)}") state_dict = torch.load(model_path, map_location="cpu", weights_only=False) if isinstance(state_dict, dict) and "model" in state_dict: state_dict = state_dict["model"] missing, unexpected = self.model.load_state_dict(state_dict, strict=False) n_missing = len(missing) n_unexpected = len(unexpected) if n_missing > 0: # Missing keys are expected: WavLM base weights (loaded from HF) + loss weights non_wavlm_missing = [k for k in missing if not k.startswith("frontend.")] if non_wavlm_missing: print(f" Warning: {len(non_wavlm_missing)} unexpected missing keys: {non_wavlm_missing[:3]}") else: print(f" {n_missing} missing keys (WavLM base + loss — loaded from HF / not needed)") if n_unexpected > 0: print(f" Warning: {n_unexpected} unexpected keys: {unexpected[:3]}") print(" Checkpoint loaded successfully.") @torch.no_grad() def extract(self, audio_path: str) -> np.ndarray: """Extract a 512-dim L2-normalized speaker embedding from an audio file. Args: audio_path: Path to a WAV file (any sample rate, auto-resampled to 16kHz). Returns: (512,) numpy float32 array, L2-normalized (unit length on hypersphere). """ audio = self._load_audio(audio_path) speech = self._preprocess(audio) speech = speech.unsqueeze(0).to(self.device) # (1, num_eval, T) # Flatten num_eval into batch for forward pass org_shape = speech.shape[:2] speech = speech.flatten(0, 1) # (num_eval, T) embds = self.model(speech=speech, spk_labels=None, extract_embd=True, task_tokens=None) embds = F.normalize(embds, p=2, dim=1) # L2 normalize # Average across num_eval crops embds = embds.view(org_shape[0], org_shape[1], -1) # (1, num_eval, 512) embd = embds.mean(dim=1) # (1, 512) embd = F.normalize(embd, p=2, dim=1) # re-normalize after averaging return embd.squeeze(0).cpu().numpy() @torch.no_grad() def extract_batch(self, audio_paths: List[str]) -> np.ndarray: """Extract 512-dim L2-normalized embeddings for multiple audio files. Args: audio_paths: List of paths to WAV files. Returns: (N, 512) numpy float32 array, L2-normalized. """ embeddings = [] for i in range(0, len(audio_paths), self.batch_size): batch_paths = audio_paths[i:i + self.batch_size] batch_speech = [] for path in batch_paths: audio = self._load_audio(path) speech = self._preprocess(audio) batch_speech.append(speech) speech_tensor = torch.stack(batch_speech, dim=0).to(self.device) org_shape = speech_tensor.shape[:2] speech_tensor = speech_tensor.flatten(0, 1) # (B*num_eval, T) embds = self.model(speech=speech_tensor, spk_labels=None, extract_embd=True, task_tokens=None) embds = F.normalize(embds, p=2, dim=1) embds = embds.view(org_shape[0], org_shape[1], -1) embds = embds.mean(dim=1) embds = F.normalize(embds, p=2, dim=1) embeddings.append(embds.cpu().numpy()) return np.concatenate(embeddings, axis=0) def _load_audio(self, path: str, sample_rate: int = 16000) -> np.ndarray: """Load audio file as float32 numpy array at target sample rate.""" audio, sr = sf.read(path, dtype="float32") if len(audio.shape) > 1: audio = audio[:, 0] if sr != sample_rate: import librosa audio = librosa.resample(audio, orig_sr=sr, target_sr=sample_rate) return audio def _preprocess(self, audio: np.ndarray) -> torch.Tensor: """Preprocess audio: pad/crop to target_duration, num_eval random crops.""" data = {"speech": audio, "speech2": audio} data = self.preprocessor._speech_process(data) speech = data["speech"] if not isinstance(speech, torch.Tensor): speech = torch.from_numpy(np.asarray(speech)) if speech.dim() == 1: speech = speech.unsqueeze(0) return speech # (num_eval, T) @classmethod def from_pretrained(cls, repo_id: str = "Paytmlabs/Pi-indic-speaker-embed-v1", device: str = "auto", **kwargs) -> "PiIndicSpeakerEmbed": """Download model files from HuggingFace and initialize. Args: repo_id: HuggingFace repo ID (default: "Paytmlabs/Pi-indic-speaker-embed-v1") device: "cuda", "cpu", or "auto" Returns: PiIndicSpeakerEmbed instance ready for inference. """ from huggingface_hub import hf_hub_download # Download files from HuggingFace model_path = hf_hub_download(repo_id=repo_id, filename="valid.eer.ave_5best_stripped.pth") config_path = hf_hub_download(repo_id=repo_id, filename="config.yaml") # Also need the custom code files code_dir = os.path.dirname(model_path) for fname in ["lora_s3prl_frontend.py", "lora_wavlm.py", "spk_preprocessor_lora.py"]: hf_hub_download(repo_id=repo_id, filename=fname) # Add code dir to path if code_dir not in sys.path: sys.path.insert(0, code_dir) return cls(model_path=model_path, config_path=config_path, device=device, **kwargs) def _register_custom_components(): """Register custom LoRA frontend and preprocessor into ESPnet.""" from espnet2.tasks.spk import frontend_choices, preprocessor_choices from lora_s3prl_frontend import LoRAS3prlFrontend from spk_preprocessor_lora import SpkLoRAPreprocessor frontend_choices.classes["lora_s3prl"] = LoRAS3prlFrontend preprocessor_choices.classes["spk_lora"] = SpkLoRAPreprocessor def main(): parser = argparse.ArgumentParser(description="Extract speaker embeddings with Pi-Indic-Speaker-Embed-v1") parser.add_argument("--audio", nargs="+", required=True, help="Path(s) to audio file(s)") parser.add_argument("--output", default=None, help="Output .npy file (default: print to stdout)") parser.add_argument("--model_path", default=None, help="Path to checkpoint (if not downloading from HF)") parser.add_argument("--config_path", default=None, help="Path to config YAML (if not downloading from HF)") parser.add_argument("--repo_id", default="Paytmlabs/Pi-indic-speaker-embed-v1", help="HF repo ID") parser.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"]) args = parser.parse_args() if args.model_path and args.config_path: model = PiIndicSpeakerEmbed(args.model_path, args.config_path, device=args.device) else: model = PiIndicSpeakerEmbed.from_pretrained(args.repo_id, device=args.device) if len(args.audio) == 1: emb = model.extract(args.audio[0]) embeddings = emb.reshape(1, -1) else: embeddings = model.extract_batch(args.audio) print(f"Embedding shape: {embeddings.shape}") print(f"L2 norm: {np.linalg.norm(embeddings, axis=1)}") if args.output: np.save(args.output, embeddings) print(f"Saved to {args.output}") else: np.set_printoptions(precision=4, suppress=True) for i, (path, emb) in enumerate(zip(args.audio, embeddings)): print(f"\n{path}:") print(f" {emb}") if __name__ == "__main__": main()