fosters commited on
Commit
94535c8
Β·
verified Β·
1 Parent(s): 8c22aaf

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +104 -99
app.py CHANGED
@@ -17,6 +17,7 @@ from transformers import Wav2Vec2FeatureExtractor
17
  os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
18
 
19
  N_CPUS = os.cpu_count() or 2
 
20
 
21
  DATASETS_SERVER = "https://datasets-server.huggingface.co"
22
  ONNX_MODEL_ID = "fosters/wavlm-base-plus-sv-onnx"
@@ -32,26 +33,39 @@ def _load_model():
32
  with _init_lock:
33
  if _model is None:
34
  _feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(ONNX_MODEL_ID)
35
- # ONNX Runtime: thread-safe, no GIL concerns
36
  _model = ORTModelForAudioXVector.from_pretrained(ONNX_MODEL_ID)
37
  return _feature_extractor, _model
38
 
39
 
40
- def _embed(audio_array: np.ndarray, sr: int, max_sec: int) -> np.ndarray:
41
- fe, mdl = _load_model()
42
  waveform = torch.tensor(audio_array, dtype=torch.float32)
43
  if waveform.ndim == 2:
44
  waveform = waveform.mean(0)
45
  if sr != TARGET_SR:
46
  waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
47
- waveform = waveform[: max_sec * TARGET_SR]
48
- inputs = fe(waveform.numpy(), sampling_rate=TARGET_SR, return_tensors="pt")
49
- out = mdl(**inputs)
50
- return out.embeddings.squeeze().numpy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
 
52
 
53
  def _fetch_audio_urls(repo_id: str, n: int, token: str | None) -> tuple[list[str], str]:
54
- """Get direct audio URLs via datasets-server. Returns (urls, debug_info)."""
55
  headers = {"Authorization": f"Bearer {token}"} if token else {}
56
  resp = requests.get(
57
  f"{DATASETS_SERVER}/rows",
@@ -66,104 +80,70 @@ def _fetch_audio_urls(repo_id: str, n: int, token: str | None) -> tuple[list[str
66
  if not rows:
67
  return [], "datasets-server: empty rows"
68
 
69
- # Inspect actual audio field structure for debugging
70
  sample_audio = rows[0]["row"].get("audio", {})
71
  audio_keys = list(sample_audio.keys()) if isinstance(sample_audio, dict) else type(sample_audio).__name__
72
 
73
  urls = []
74
  for row in rows:
75
  audio = row["row"].get("audio", {})
76
- # audio can be a dict {"src": ...} or a list [{"src": ...}, ...]
77
  if isinstance(audio, list):
78
  audio = audio[0] if audio else {}
79
  if isinstance(audio, dict) and "src" in audio:
80
  urls.append(audio["src"])
81
 
82
  if not urls:
83
- return [], f"datasets-server: no src in audio field (keys={audio_keys})"
84
- return urls, f"datasets-server ok (audio keys={audio_keys})"
85
 
86
 
87
- def _download_one(url: str, token: str | None, max_sec: int) -> tuple[np.ndarray, dict]:
 
88
  headers = {"Authorization": f"Bearer {token}"} if token else {}
89
- t0 = time.time()
90
  resp = requests.get(url, headers=headers, timeout=30)
91
  resp.raise_for_status()
92
- dl_ms = (time.time() - t0) * 1000
93
-
94
- t1 = time.time()
95
  audio_array, sr = sf.read(io.BytesIO(resp.content))
96
- decode_ms = (time.time() - t1) * 1000
97
-
98
- t2 = time.time()
99
- emb = _embed(audio_array, sr, max_sec)
100
- embed_ms = (time.time() - t2) * 1000
101
-
102
- return emb, {
103
- "size_kb": len(resp.content) // 1024,
104
- "dl_ms": int(dl_ms),
105
- "decode_ms": int(decode_ms),
106
- "embed_ms": int(embed_ms),
107
- }
108
-
109
-
110
- def _streaming_fallback(repo: str, n_samples: int, audio_sec: int, token: str | None) -> list[np.ndarray]:
111
- """Fallback: use datasets streaming when datasets-server is unavailable."""
112
- from datasets import load_dataset, Audio as HFAudio
113
- ds = load_dataset(repo, split="train", streaming=True, token=token)
114
- ds = ds.cast_column("audio", HFAudio(decode=False))
115
- embs = []
116
- for j, row in enumerate(ds):
117
- if j >= n_samples:
118
- break
119
- raw = row["audio"]
120
- audio_bytes = raw.get("bytes") or open(raw["path"], "rb").read()
121
- audio_array, sr = sf.read(io.BytesIO(audio_bytes))
122
- embs.append(_embed(audio_array, sr, audio_sec))
123
- return embs
124
-
125
-
126
- def _process_repo(
127
  repo: str, n_samples: int, audio_sec: int, token: str | None
128
- ) -> tuple[str, np.ndarray | None, str]:
 
129
  t0 = time.time()
130
  try:
131
- urls, api_debug = _fetch_audio_urls(repo, n_samples, token)
132
  fetch_ms = int((time.time() - t0) * 1000)
133
 
134
  if urls:
135
- # Fast path: parallel downloads via datasets-server URLs
136
  with concurrent.futures.ThreadPoolExecutor(max_workers=len(urls)) as ex:
137
- futures = [ex.submit(_download_one, u, token, audio_sec) for u in urls]
138
- results = [f.result() for f in futures]
139
- embs = [r[0] for r in results]
140
- s = results[0][1]
141
- total_ms = int((time.time() - t0) * 1000)
142
- log = (
143
- f"{repo.split('/')[-1]}: "
144
- f"api={fetch_ms}ms dl={s['dl_ms']}ms "
145
- f"decode={s['decode_ms']}ms embed={s['embed_ms']}ms "
146
- f"total={total_ms}ms ({s['size_kb']}KB/file) [{api_debug}]"
147
- )
148
- else:
149
- # Slow fallback: streaming (downloads parquet shard)
150
- t1 = time.time()
151
- embs = _streaming_fallback(repo, n_samples, audio_sec, token)
152
- total_ms = int((time.time() - t0) * 1000)
153
- log = (
154
- f"{repo.split('/')[-1]}: streaming fallback "
155
- f"total={total_ms}ms [reason: {api_debug}]"
156
- )
157
-
158
- if not embs:
159
- return repo, None, f"{repo}: no audio samples extracted"
160
-
161
- return repo, np.mean(embs, axis=0), log
162
 
163
  except Exception as exc:
164
- return repo, None, f"{repo}: ERROR {exc}"
165
 
166
 
 
 
167
  def identify_speakers(
168
  repo_ids_text: str,
169
  samples_per_book: int,
@@ -180,31 +160,56 @@ def identify_speakers(
180
 
181
  progress(0, desc="Loading model…")
182
  _load_model()
183
- progress(0.02, desc=f"Processing {len(repos)} datasets in parallel…")
184
 
185
- embeddings: dict[str, np.ndarray] = {}
186
- logs: list[str] = []
 
 
187
  errors: list[str] = []
188
- done = 0
189
 
190
- # Process repos in parallel β€” capped at N_CPUS since embed is the bottleneck
191
- with concurrent.futures.ThreadPoolExecutor(max_workers=N_CPUS) as ex:
192
- future_to_repo = {
193
- ex.submit(_process_repo, repo, int(samples_per_book), int(audio_sec), token): repo
194
  for repo in repos
195
  }
196
- for future in concurrent.futures.as_completed(future_to_repo):
197
- repo, emb, log = future.result()
 
198
  done += 1
199
- progress(done / len(repos), desc=f"[{done}/{len(repos)}] {repo.split('/')[-1]}")
200
- if emb is not None:
201
- embeddings[repo] = emb
202
- logs.append(log)
203
  else:
204
- errors.append(log)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
- if not embeddings:
207
- return pd.DataFrame(), "No embeddings extracted.", "\n".join(errors)
 
 
 
208
 
209
  repo_names = list(embeddings.keys())
210
  emb_matrix = np.stack([embeddings[r] for r in repo_names])
@@ -246,7 +251,7 @@ def identify_speakers(
246
  n_speakers = len(set(labels))
247
  summary = f"βœ… {len(repo_names)} books β†’ {n_speakers} unique speakers"
248
 
249
- debug = "\n".join(logs)
250
  if errors:
251
  debug += "\n\nERRORS:\n" + "\n".join(errors)
252
  return df, summary, debug
@@ -257,9 +262,9 @@ DESCRIPTION = """
257
 
258
  Finds unique speakers across multiple HF audio datasets. Each dataset is assumed to have
259
  **one speaker** (e.g. an audiobook). The app fetches audio directly via the datasets-server API
260
- (no full parquet download), processes datasets in parallel, and clusters by voice similarity.
261
 
262
- **Model:** [microsoft/wavlm-base-plus-sv](https://huggingface.co/microsoft/wavlm-base-plus-sv) β€”
263
  language-agnostic speaker embeddings, works for any language.
264
 
265
  ---
@@ -272,7 +277,7 @@ language-agnostic speaker embeddings, works for any language.
272
  - *Audio length (sec)* β€” seconds of each chunk to use for embedding. 5 sec is sufficient for a clear voice.
273
  - *Same-speaker threshold* β€” cosine similarity cutoff. Raise if too many books merge into one speaker; lower if one person gets split across IDs.
274
  - *HF Token* β€” only needed for **private** repos.
275
- 3. Click **Identify Speakers**. Datasets are processed in parallel β€” expect ~30–60 sec for 35 books.
276
 
277
  ## Output columns
278
 
@@ -286,7 +291,7 @@ language-agnostic speaker embeddings, works for any language.
286
 
287
  **Tip:** Sort by `speaker_id` to see all books by the same narrator grouped together.
288
 
289
- The **Errors / Timing** box shows per-dataset timing breakdown (API fetch / download / decode / embed) β€” useful for diagnosing slow datasets.
290
  """
291
 
292
  with gr.Blocks(title="Speaker Identifier") as demo:
 
17
  os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
18
 
19
  N_CPUS = os.cpu_count() or 2
20
+ BATCH_SIZE = 64 # max clips per ONNX forward pass
21
 
22
  DATASETS_SERVER = "https://datasets-server.huggingface.co"
23
  ONNX_MODEL_ID = "fosters/wavlm-base-plus-sv-onnx"
 
33
  with _init_lock:
34
  if _model is None:
35
  _feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(ONNX_MODEL_ID)
 
36
  _model = ORTModelForAudioXVector.from_pretrained(ONNX_MODEL_ID)
37
  return _feature_extractor, _model
38
 
39
 
40
+ def _to_array(audio_array: np.ndarray, sr: int, max_sec: int) -> np.ndarray:
41
+ """Resample to TARGET_SR, convert to mono, trim to max_sec."""
42
  waveform = torch.tensor(audio_array, dtype=torch.float32)
43
  if waveform.ndim == 2:
44
  waveform = waveform.mean(0)
45
  if sr != TARGET_SR:
46
  waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
47
+ return waveform[: max_sec * TARGET_SR].numpy().astype(np.float32)
48
+
49
+
50
+ def _batch_embed(arrays: list[np.ndarray]) -> np.ndarray:
51
+ """Run all clips through the model in batches. Returns (N, D) embeddings."""
52
+ fe, mdl = _load_model()
53
+ all_embs = []
54
+ for i in range(0, len(arrays), BATCH_SIZE):
55
+ batch = arrays[i : i + BATCH_SIZE]
56
+ inputs = fe(batch, sampling_rate=TARGET_SR, return_tensors="pt", padding=True)
57
+ out = mdl(**inputs)
58
+ # embeddings shape: (batch, D)
59
+ embs = out.embeddings.detach().numpy()
60
+ if embs.ndim == 1:
61
+ embs = embs[np.newaxis]
62
+ all_embs.append(embs)
63
+ return np.concatenate(all_embs, axis=0)
64
+
65
 
66
+ # ── Audio fetching ────────────────────────────────────────────────────────────
67
 
68
  def _fetch_audio_urls(repo_id: str, n: int, token: str | None) -> tuple[list[str], str]:
 
69
  headers = {"Authorization": f"Bearer {token}"} if token else {}
70
  resp = requests.get(
71
  f"{DATASETS_SERVER}/rows",
 
80
  if not rows:
81
  return [], "datasets-server: empty rows"
82
 
 
83
  sample_audio = rows[0]["row"].get("audio", {})
84
  audio_keys = list(sample_audio.keys()) if isinstance(sample_audio, dict) else type(sample_audio).__name__
85
 
86
  urls = []
87
  for row in rows:
88
  audio = row["row"].get("audio", {})
 
89
  if isinstance(audio, list):
90
  audio = audio[0] if audio else {}
91
  if isinstance(audio, dict) and "src" in audio:
92
  urls.append(audio["src"])
93
 
94
  if not urls:
95
+ return [], f"no src in audio (keys={audio_keys})"
96
+ return urls, "ok"
97
 
98
 
99
+ def _download_audio(url: str, token: str | None, max_sec: int) -> tuple[np.ndarray, int]:
100
+ """Download one audio file. Returns (array_at_TARGET_SR, size_kb)."""
101
  headers = {"Authorization": f"Bearer {token}"} if token else {}
 
102
  resp = requests.get(url, headers=headers, timeout=30)
103
  resp.raise_for_status()
 
 
 
104
  audio_array, sr = sf.read(io.BytesIO(resp.content))
105
+ return _to_array(audio_array, sr, max_sec), len(resp.content) // 1024
106
+
107
+
108
+ def _fetch_repo_audio(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  repo: str, n_samples: int, audio_sec: int, token: str | None
110
+ ) -> tuple[str, list[np.ndarray], str]:
111
+ """Fetch + download audio for one repo. Returns (repo, arrays, log)."""
112
  t0 = time.time()
113
  try:
114
+ urls, api_status = _fetch_audio_urls(repo, n_samples, token)
115
  fetch_ms = int((time.time() - t0) * 1000)
116
 
117
  if urls:
 
118
  with concurrent.futures.ThreadPoolExecutor(max_workers=len(urls)) as ex:
119
+ results = list(ex.map(lambda u: _download_audio(u, token, audio_sec), urls))
120
+ arrays = [r[0] for r in results]
121
+ avg_kb = int(np.mean([r[1] for r in results]))
122
+ dl_ms = int((time.time() - t0) * 1000) - fetch_ms
123
+ log = f"api={fetch_ms}ms dl={dl_ms}ms ({avg_kb}KB/file) [{api_status}]"
124
+ return repo, arrays, log
125
+
126
+ # Streaming fallback
127
+ from datasets import load_dataset, Audio as HFAudio
128
+ ds = load_dataset(repo, split="train", streaming=True, token=token)
129
+ ds = ds.cast_column("audio", HFAudio(decode=False))
130
+ arrays = []
131
+ for j, row in enumerate(ds):
132
+ if j >= n_samples:
133
+ break
134
+ raw = row["audio"]
135
+ audio_bytes = raw.get("bytes") or open(raw["path"], "rb").read()
136
+ a, sr = sf.read(io.BytesIO(audio_bytes))
137
+ arrays.append(_to_array(a, sr, audio_sec))
138
+ total_ms = int((time.time() - t0) * 1000)
139
+ return repo, arrays, f"streaming fallback total={total_ms}ms [reason: {api_status}]"
 
 
 
 
140
 
141
  except Exception as exc:
142
+ return repo, [], f"ERROR: {exc}"
143
 
144
 
145
+ # ── Main ──────────────────────────────────────────────────────────────────────
146
+
147
  def identify_speakers(
148
  repo_ids_text: str,
149
  samples_per_book: int,
 
160
 
161
  progress(0, desc="Loading model…")
162
  _load_model()
 
163
 
164
+ # ── Phase 1: download all audio in parallel (I/O bound) ──────────────────
165
+ progress(0.05, desc=f"Downloading audio from {len(repos)} datasets…")
166
+ repo_arrays: dict[str, list[np.ndarray]] = {}
167
+ fetch_logs: list[str] = []
168
  errors: list[str] = []
 
169
 
170
+ with concurrent.futures.ThreadPoolExecutor(max_workers=N_CPUS * 2) as ex:
171
+ futures = {
172
+ ex.submit(_fetch_repo_audio, repo, int(samples_per_book), int(audio_sec), token): repo
 
173
  for repo in repos
174
  }
175
+ done = 0
176
+ for future in concurrent.futures.as_completed(futures):
177
+ repo, arrays, log = future.result()
178
  done += 1
179
+ progress(0.05 + 0.55 * done / len(repos), desc=f"[{done}/{len(repos)}] {repo.split('/')[-1]}")
180
+ if arrays:
181
+ repo_arrays[repo] = arrays
182
+ fetch_logs.append(f"{repo.split('/')[-1]}: {log}")
183
  else:
184
+ errors.append(f"{repo.split('/')[-1]}: {log}")
185
+
186
+ if not repo_arrays:
187
+ return pd.DataFrame(), "No audio downloaded.", "\n".join(errors)
188
+
189
+ # ── Phase 2: batch embed all clips in one shot ───────────────────────────
190
+ progress(0.60, desc="Batch embedding all clips…")
191
+ all_arrays: list[np.ndarray] = []
192
+ repo_slices: dict[str, tuple[int, int]] = {}
193
+
194
+ for repo in repos:
195
+ if repo in repo_arrays:
196
+ start = len(all_arrays)
197
+ all_arrays.extend(repo_arrays[repo])
198
+ repo_slices[repo] = (start, len(all_arrays))
199
+
200
+ t_embed = time.time()
201
+ all_embeddings = _batch_embed(all_arrays) # (N_clips, D)
202
+ embed_ms = int((time.time() - t_embed) * 1000)
203
+ fetch_logs.append(
204
+ f"batch embed: {len(all_arrays)} clips in {embed_ms}ms "
205
+ f"({embed_ms // len(all_arrays)}ms/clip avg)"
206
+ )
207
 
208
+ # ── Phase 3: average per repo, cluster ───────────────────────────────────
209
+ progress(0.95, desc="Clustering…")
210
+ embeddings: dict[str, np.ndarray] = {}
211
+ for repo, (start, end) in repo_slices.items():
212
+ embeddings[repo] = all_embeddings[start:end].mean(axis=0)
213
 
214
  repo_names = list(embeddings.keys())
215
  emb_matrix = np.stack([embeddings[r] for r in repo_names])
 
251
  n_speakers = len(set(labels))
252
  summary = f"βœ… {len(repo_names)} books β†’ {n_speakers} unique speakers"
253
 
254
+ debug = "\n".join(fetch_logs)
255
  if errors:
256
  debug += "\n\nERRORS:\n" + "\n".join(errors)
257
  return df, summary, debug
 
262
 
263
  Finds unique speakers across multiple HF audio datasets. Each dataset is assumed to have
264
  **one speaker** (e.g. an audiobook). The app fetches audio directly via the datasets-server API
265
+ (no full parquet download), downloads in parallel, then embeds all clips in one batched forward pass.
266
 
267
+ **Model:** [microsoft/wavlm-base-plus-sv](https://huggingface.co/microsoft/wavlm-base-plus-sv) (ONNX) β€”
268
  language-agnostic speaker embeddings, works for any language.
269
 
270
  ---
 
277
  - *Audio length (sec)* β€” seconds of each chunk to use for embedding. 5 sec is sufficient for a clear voice.
278
  - *Same-speaker threshold* β€” cosine similarity cutoff. Raise if too many books merge into one speaker; lower if one person gets split across IDs.
279
  - *HF Token* β€” only needed for **private** repos.
280
+ 3. Click **Identify Speakers**. Downloads run in parallel, then all clips are embedded in one batch β€” expect ~20–40 sec for 35 books.
281
 
282
  ## Output columns
283
 
 
291
 
292
  **Tip:** Sort by `speaker_id` to see all books by the same narrator grouped together.
293
 
294
+ The **Errors / Timing** box shows per-dataset timing and batch embed stats β€” useful for diagnosing slow datasets.
295
  """
296
 
297
  with gr.Blocks(title="Speaker Identifier") as demo: