File size: 13,189 Bytes
f297ff2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
#!/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()