fosters's picture
Upload app.py with huggingface_hub
a845faf verified
Raw
History Blame
12.7 kB
import io
import os
import time
import threading
import concurrent.futures
import gradio as gr
import numpy as np
import pandas as pd
import requests
import soundfile as sf
import torchaudio
import torch
from sklearn.cluster import AgglomerativeClustering
from optimum.onnxruntime import ORTModelForAudioXVector
from transformers import Wav2Vec2FeatureExtractor
os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
N_CPUS = os.cpu_count() or 2
DATASETS_SERVER = "https://datasets-server.huggingface.co"
ONNX_MODEL_ID = "fosters/wavlm-base-plus-sv-onnx"
TARGET_SR = 16000
_feature_extractor = None
_model = None
_init_lock = threading.Lock()
def _load_model():
global _feature_extractor, _model
with _init_lock:
if _model is None:
_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(ONNX_MODEL_ID)
# ONNX Runtime: thread-safe, no GIL concerns
_model = ORTModelForAudioXVector.from_pretrained(ONNX_MODEL_ID)
return _feature_extractor, _model
def _embed(audio_array: np.ndarray, sr: int, max_sec: int) -> np.ndarray:
fe, mdl = _load_model()
waveform = torch.tensor(audio_array, dtype=torch.float32)
if waveform.ndim == 2:
waveform = waveform.mean(0)
if sr != TARGET_SR:
waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
waveform = waveform[: max_sec * TARGET_SR]
inputs = fe(waveform.numpy(), sampling_rate=TARGET_SR, return_tensors="pt")
out = mdl(**inputs)
return out.embeddings.squeeze().numpy()
def _fetch_audio_urls(repo_id: str, n: int, token: str | None) -> tuple[list[str], str]:
"""Get direct audio URLs via datasets-server. Returns (urls, debug_info)."""
headers = {"Authorization": f"Bearer {token}"} if token else {}
resp = requests.get(
f"{DATASETS_SERVER}/rows",
params={"dataset": repo_id, "config": "default", "split": "train", "offset": 0, "length": n},
headers=headers,
timeout=30,
)
if not resp.ok:
return [], f"datasets-server {resp.status_code}"
rows = resp.json().get("rows", [])
if not rows:
return [], "datasets-server: empty rows"
# Inspect actual audio field structure for debugging
sample_audio = rows[0]["row"].get("audio", {})
audio_keys = list(sample_audio.keys()) if isinstance(sample_audio, dict) else type(sample_audio).__name__
urls = []
for row in rows:
audio = row["row"].get("audio", {})
# audio can be a dict {"src": ...} or a list [{"src": ...}, ...]
if isinstance(audio, list):
audio = audio[0] if audio else {}
if isinstance(audio, dict) and "src" in audio:
urls.append(audio["src"])
if not urls:
return [], f"datasets-server: no src in audio field (keys={audio_keys})"
return urls, f"datasets-server ok (audio keys={audio_keys})"
def _download_one(url: str, token: str | None, max_sec: int) -> tuple[np.ndarray, dict]:
headers = {"Authorization": f"Bearer {token}"} if token else {}
t0 = time.time()
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
dl_ms = (time.time() - t0) * 1000
t1 = time.time()
audio_array, sr = sf.read(io.BytesIO(resp.content))
decode_ms = (time.time() - t1) * 1000
t2 = time.time()
emb = _embed(audio_array, sr, max_sec)
embed_ms = (time.time() - t2) * 1000
return emb, {
"size_kb": len(resp.content) // 1024,
"dl_ms": int(dl_ms),
"decode_ms": int(decode_ms),
"embed_ms": int(embed_ms),
}
def _streaming_fallback(repo: str, n_samples: int, audio_sec: int, token: str | None) -> list[np.ndarray]:
"""Fallback: use datasets streaming when datasets-server is unavailable."""
from datasets import load_dataset, Audio as HFAudio
ds = load_dataset(repo, split="train", streaming=True, token=token)
ds = ds.cast_column("audio", HFAudio(decode=False))
embs = []
for j, row in enumerate(ds):
if j >= n_samples:
break
raw = row["audio"]
audio_bytes = raw.get("bytes") or open(raw["path"], "rb").read()
audio_array, sr = sf.read(io.BytesIO(audio_bytes))
embs.append(_embed(audio_array, sr, audio_sec))
return embs
def _process_repo(
repo: str, n_samples: int, audio_sec: int, token: str | None
) -> tuple[str, np.ndarray | None, str]:
t0 = time.time()
try:
urls, api_debug = _fetch_audio_urls(repo, n_samples, token)
fetch_ms = int((time.time() - t0) * 1000)
if urls:
# Fast path: parallel downloads via datasets-server URLs
with concurrent.futures.ThreadPoolExecutor(max_workers=len(urls)) as ex:
futures = [ex.submit(_download_one, u, token, audio_sec) for u in urls]
results = [f.result() for f in futures]
embs = [r[0] for r in results]
s = results[0][1]
total_ms = int((time.time() - t0) * 1000)
log = (
f"{repo.split('/')[-1]}: "
f"api={fetch_ms}ms dl={s['dl_ms']}ms "
f"decode={s['decode_ms']}ms embed={s['embed_ms']}ms "
f"total={total_ms}ms ({s['size_kb']}KB/file) [{api_debug}]"
)
else:
# Slow fallback: streaming (downloads parquet shard)
t1 = time.time()
embs = _streaming_fallback(repo, n_samples, audio_sec, token)
total_ms = int((time.time() - t0) * 1000)
log = (
f"{repo.split('/')[-1]}: streaming fallback "
f"total={total_ms}ms [reason: {api_debug}]"
)
if not embs:
return repo, None, f"{repo}: no audio samples extracted"
return repo, np.mean(embs, axis=0), log
except Exception as exc:
return repo, None, f"{repo}: ERROR {exc}"
def identify_speakers(
repo_ids_text: str,
samples_per_book: int,
audio_sec: int,
threshold: float,
hf_token: str,
progress=gr.Progress(),
):
repos = [r.strip() for r in repo_ids_text.strip().splitlines() if r.strip()]
if not repos:
return pd.DataFrame(), "No repos provided.", ""
token = hf_token.strip() or os.environ.get("HF_TOKEN") or None
progress(0, desc="Loading model…")
_load_model()
progress(0.02, desc=f"Processing {len(repos)} datasets in parallel…")
embeddings: dict[str, np.ndarray] = {}
logs: list[str] = []
errors: list[str] = []
done = 0
# Process repos in parallel β€” capped at N_CPUS since embed is the bottleneck
with concurrent.futures.ThreadPoolExecutor(max_workers=N_CPUS) as ex:
future_to_repo = {
ex.submit(_process_repo, repo, int(samples_per_book), int(audio_sec), token): repo
for repo in repos
}
for future in concurrent.futures.as_completed(future_to_repo):
repo, emb, log = future.result()
done += 1
progress(done / len(repos), desc=f"[{done}/{len(repos)}] {repo.split('/')[-1]}")
if emb is not None:
embeddings[repo] = emb
logs.append(log)
else:
errors.append(log)
if not embeddings:
return pd.DataFrame(), "No embeddings extracted.", "\n".join(errors)
repo_names = list(embeddings.keys())
emb_matrix = np.stack([embeddings[r] for r in repo_names])
emb_matrix = emb_matrix / np.linalg.norm(emb_matrix, axis=1, keepdims=True)
sim_matrix = np.clip(emb_matrix @ emb_matrix.T, -1.0, 1.0)
dist_matrix = 1.0 - sim_matrix
np.fill_diagonal(dist_matrix, 0.0)
n = len(repo_names)
if n == 1:
labels = [0]
else:
labels = AgglomerativeClustering(
n_clusters=None,
distance_threshold=1.0 - float(threshold),
metric="precomputed",
linkage="average",
).fit_predict(dist_matrix).tolist()
rows = []
for i, repo in enumerate(repo_names):
cluster = labels[i]
same_idx = [j for j, l in enumerate(labels) if l == cluster and j != i]
intra_sim = float(np.mean([sim_matrix[i][j] for j in same_idx])) if same_idx else 1.0
other_sorted = sorted([j for j in range(n) if j != i], key=lambda j: -sim_matrix[i][j])
closest = (
f"{repo_names[other_sorted[0]].split('/')[-1]} ({sim_matrix[i][other_sorted[0]]:.2f})"
if other_sorted else "-"
)
rows.append({
"dataset": repo.split("/")[-1],
"speaker_id": f"speaker_{cluster + 1:02d}",
"books_with_speaker": sum(1 for l in labels if l == cluster),
"intra_sim": round(intra_sim, 3),
"closest_match": closest,
})
df = pd.DataFrame(rows).sort_values(["speaker_id", "dataset"]).reset_index(drop=True)
n_speakers = len(set(labels))
summary = f"βœ… {len(repo_names)} books β†’ {n_speakers} unique speakers"
debug = "\n".join(logs)
if errors:
debug += "\n\nERRORS:\n" + "\n".join(errors)
return df, summary, debug
DESCRIPTION = """
# πŸŽ™οΈ Speaker Identifier
Finds unique speakers across multiple HF audio datasets. Each dataset is assumed to have
**one speaker** (e.g. an audiobook). The app fetches audio directly via the datasets-server API
(no full parquet download), processes datasets in parallel, and clusters by voice similarity.
**Model:** [microsoft/wavlm-base-plus-sv](https://huggingface.co/microsoft/wavlm-base-plus-sv) β€”
language-agnostic speaker embeddings, works for any language.
---
## How to use
1. **Paste dataset repo IDs** (one per line, `owner/name` format) into the left box.
2. **Adjust parameters** if needed (defaults work well for audiobooks):
- *Samples per book* β€” audio chunks to average per dataset. More = more robust, slower. 3 is usually enough.
- *Audio length (sec)* β€” seconds of each chunk to use for embedding. 5 sec is sufficient for a clear voice.
- *Same-speaker threshold* β€” cosine similarity cutoff. Raise if too many books merge into one speaker; lower if one person gets split across IDs.
- *HF Token* β€” only needed for **private** repos.
3. Click **Identify Speakers**. Datasets are processed in parallel β€” expect ~30–60 sec for 35 books.
## Output columns
| Column | Meaning |
|---|---|
| `dataset` | Repo name (short) |
| `speaker_id` | Cluster label β€” same ID = same voice |
| `books_with_speaker` | How many books share this speaker |
| `intra_sim` | Avg cosine similarity within cluster (1.0 = only one book; lower = cluster is less tight) |
| `closest_match` | Most similar other book and similarity score |
**Tip:** Sort by `speaker_id` to see all books by the same narrator grouped together.
The **Errors / Timing** box shows per-dataset timing breakdown (API fetch / download / decode / embed) β€” useful for diagnosing slow datasets.
"""
with gr.Blocks(title="Speaker Identifier") as demo:
gr.Markdown(DESCRIPTION)
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=2):
repo_input = gr.Textbox(
label="Dataset repo IDs (one per line)",
placeholder="fosters/some_audiobook_output\nfosters/another_audiobook_output",
lines=16,
)
with gr.Column(scale=1):
samples = gr.Slider(1, 10, value=3, step=1, label="Samples per book")
audio_sec = gr.Slider(
2, 30, value=5, step=1,
label="Audio length per sample (sec)",
info="5 sec is usually enough; longer = more accurate but slower",
)
threshold = gr.Slider(
0.60, 0.98, value=0.82, step=0.01,
label="Same-speaker threshold",
info="Higher = stricter matching β†’ more clusters",
)
hf_token = gr.Textbox(
label="HF Token (private repos only)",
type="password",
placeholder="hf_…",
)
run_btn = gr.Button("Identify Speakers", variant="primary", size="lg")
summary_out = gr.Textbox(label="Summary", interactive=False)
table_out = gr.Dataframe(
label="Results β€” sorted by speaker_id",
headers=["dataset", "speaker_id", "books_with_speaker", "intra_sim", "closest_match"],
wrap=True,
)
errors_out = gr.Textbox(label="Errors / Timing", interactive=False)
run_btn.click(
identify_speakers,
inputs=[repo_input, samples, audio_sec, threshold, hf_token],
outputs=[table_out, summary_out, errors_out],
)
demo.launch()