| import json
|
| import argparse
|
| import torch
|
| import numpy as np
|
| from pathlib import Path
|
| from tqdm import tqdm
|
| from pyannote.audio import Model, Inference
|
| from sklearn.cluster import AgglomerativeClustering
|
|
|
|
|
| """
|
| tools/cluster_with_pyannote.py
|
|
|
| WHAT THIS SCRIPT DOES:
|
| ----------------------
|
| 1. LOADS DATA: Reads your 'train_manifest.jsonl' to find all your audio files.
|
| 2. EXTRACTS "FINGERPRINTS": Uses the `pyannote/embedding` model to listen to each file
|
| and convert the voice into a mathematical vector (embedding).
|
| This is far more accurate than the previous Method.
|
| 3. CLUSTERS THEM: Instead of just comparing A vs B, it collects ALL fingerprints
|
| and groups them into "Families" (Speakers) using Agglomerative Clustering.
|
| 4. SAVES RESULT: Writes a new manifest with corrected 'speaker' IDs (e.g., 'speaker_0', 'speaker_1').
|
|
|
| REQUIREMENTS:
|
| -------------
|
| - pip install pyannote.audio sklearn
|
| - HuggingFace Token (exported as env var `HF_TOKEN` or logged in via `huggingface-cli login`)
|
| - Accepted license for `pyannote/embedding` on HuggingFace Hub.
|
| """
|
|
|
| def parse_args():
|
| parser = argparse.ArgumentParser(description="Cluster speakers using Pyannote embeddings.")
|
| parser.add_argument("--input", type=Path, required=True, help="Input manifest path.")
|
| parser.add_argument("--output", type=Path, required=True, help="Output manifest path.")
|
| parser.add_argument("--token", type=str, default=None, help="HuggingFace Token (optional if logged in).")
|
| parser.add_argument("--batch-size", type=int, default=32, help="Inference batch size.")
|
| parser.add_argument("--num-clusters", type=int, default=20000,
|
| help="Number of speakers to group into. Higher = stricter (more speakers). Lower = looser (fewer).")
|
| parser.add_argument("--detect-multi-speaker", action="store_true",
|
| help="Detect files with >1 speaker using Diarization pipeline (WARNING: Very Slow!).")
|
| return parser.parse_args()
|
|
|
| def main():
|
| args = parse_args()
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| print(f"Using device: {device}")
|
|
|
|
|
| print("Loading Pyannote Embedding Model (Wespeaker)...")
|
| try:
|
|
|
| try:
|
| import pyannote.audio.core.task
|
| torch.serialization.add_safe_globals([
|
| torch.torch_version.TorchVersion,
|
| pyannote.audio.core.task.Specifications,
|
| pyannote.audio.core.task.Problem,
|
| pyannote.audio.core.task.Resolution,
|
| ])
|
| except (AttributeError, ImportError):
|
| pass
|
|
|
|
|
| model = Model.from_pretrained("pyannote/wespeaker-voxceleb-resnet34-LM", use_auth_token=args.token)
|
| inference = Inference(model, window="whole", device=device)
|
| except Exception as e:
|
| print(f"\n[ERROR] Could not load Pyannote model: {e}")
|
| print("Make sure you accepted the license at: https://huggingface.co/pyannote/wespeaker-voxceleb-resnet34-LM")
|
| print("And ran: huggingface-cli login\n")
|
| return
|
|
|
|
|
| diarization_pipeline = None
|
| if args.detect_multi_speaker:
|
| print("\n[WARNING] Multi-speaker detection is ENABLED. This will drastically increase processing time!")
|
| print("Loading Pyannote Diarization Pipeline (Offline Cache)...")
|
| from pyannote.audio import Pipeline
|
| import os
|
|
|
|
|
| cache_dir = Path(os.path.expanduser("~")) / ".cache/huggingface/hub/models--pyannote--speaker-diarization-3.1/snapshots"
|
| try:
|
|
|
| if cache_dir.exists():
|
| snap_dirs = [d for d in cache_dir.iterdir() if d.is_dir()]
|
| if snap_dirs:
|
| config_path = snap_dirs[0] / "config.yaml"
|
| diarization_pipeline = Pipeline.from_pretrained(str(config_path))
|
| diarization_pipeline.to(device)
|
| print("Diarization Pipeline loaded successfully from cache.")
|
| else:
|
| print("[ERROR] No snapshot found in diarization cache.")
|
| else:
|
| print("[ERROR] Diarization cache directory not found.")
|
| except Exception as e:
|
| print(f"[ERROR] Could not load Diarization Pipeline: {e}")
|
| return
|
|
|
|
|
| print(f"Reading manifest: {args.input}")
|
| entries = []
|
| with args.input.open("r", encoding="utf-8") as f:
|
| for line in f:
|
| if line.strip():
|
| entries.append(json.loads(line))
|
|
|
|
|
| cache_path = args.input.parent / f"{args.input.stem}_embeddings.npy"
|
| cache_ids_path = args.input.parent / f"{args.input.stem}_ids.json"
|
|
|
| if cache_path.exists() and cache_ids_path.exists():
|
| print(f"Loading cached embeddings from {cache_path}...")
|
| embeddings = np.load(cache_path)
|
| with open(cache_ids_path, 'r', encoding='utf-8') as f:
|
| valid_indices = json.load(f)
|
|
|
|
|
|
|
|
|
| valid_entries = [entries[i] for i in valid_indices]
|
|
|
| else:
|
| print(f"Extracting embeddings for {len(entries)} files...")
|
| embeddings = []
|
| valid_entries = []
|
| valid_indices = []
|
| multi_speaker_files = []
|
|
|
|
|
| dataset_root = args.input.parent.parent
|
|
|
| for i, entry in enumerate(tqdm(entries)):
|
| audio_path = Path(entry['audio_path'])
|
|
|
|
|
| possible_paths = [
|
| audio_path,
|
| Path("dataset") / audio_path,
|
| args.input.parent / audio_path,
|
| Path("thaitts_emilia_dataset") / audio_path.name
|
| ]
|
|
|
| found_path = None
|
| for p in possible_paths:
|
| if p.exists():
|
| found_path = p
|
| break
|
|
|
| if not found_path:
|
| continue
|
|
|
| try:
|
| embedding = inference(str(found_path))
|
| embeddings.append(embedding)
|
| valid_entries.append(entry)
|
| valid_indices.append(i)
|
|
|
|
|
| if diarization_pipeline is not None:
|
|
|
| diar = diarization_pipeline(str(found_path))
|
| speakers_found = diar.labels()
|
| if len(speakers_found) > 1:
|
| multi_speaker_files.append(str(found_path))
|
|
|
| except Exception:
|
| pass
|
|
|
| if not embeddings:
|
| print("No embeddings extracted.")
|
| return
|
|
|
| embeddings = np.array(embeddings)
|
| print(f"Extracted {len(embeddings)} embeddings. Saving cache...")
|
| np.save(cache_path, embeddings)
|
| with open(cache_ids_path, 'w', encoding='utf-8') as f:
|
| json.dump(valid_indices, f)
|
|
|
| if diarization_pipeline is not None:
|
| multi_out = args.input.parent / "multi_speaker_detected.txt"
|
| with open(multi_out, "w", encoding="utf-8") as f:
|
| for mf in multi_speaker_files:
|
| f.write(mf + "\n")
|
| print(f"Found {len(multi_speaker_files)} files with multiple speakers. List saved to {multi_out}")
|
|
|
| print(f"Embeddings shape: {embeddings.shape}")
|
|
|
|
|
|
|
|
|
| from sklearn.cluster import MiniBatchKMeans
|
|
|
|
|
| n_clusters = args.num_clusters
|
| if n_clusters > len(embeddings):
|
| print(f"[WARNING] You asked for {n_clusters} clusters, but there are only {len(embeddings)} audio files!")
|
| n_clusters = len(embeddings) // 2
|
| if n_clusters < 1: n_clusters = 1
|
| print(f"Auto-adjusting clusters to: {n_clusters}")
|
|
|
| print(f"Clustering into {n_clusters} estimated groups using MiniBatchKMeans...")
|
|
|
| clustering = MiniBatchKMeans(
|
| n_clusters=n_clusters,
|
| batch_size=4096,
|
| random_state=42,
|
| init='random',
|
| n_init=1
|
| ).fit(embeddings)
|
|
|
| labels = clustering.labels_
|
| num_speakers = len(set(labels))
|
| print(f"Found {num_speakers} cluster groups.")
|
|
|
|
|
| print(f"Saving to {args.output}...")
|
| args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
| with args.output.open("w", encoding="utf-8") as f:
|
| for entry, label in zip(valid_entries, labels):
|
| entry['speaker'] = f"speaker_{label}"
|
| f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
|
| print("Done! You now have a properly labeled manifest.")
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|