fosters commited on
Commit
c441c60
·
verified ·
1 Parent(s): 5266d49

Fix: per-batch progress, detailed log, slim output schema

Browse files
Files changed (1) hide show
  1. app.py +338 -273
app.py CHANGED
@@ -1,7 +1,7 @@
1
  """Chunk Classifier — HF Space.
2
 
3
  Classifies audio chunks in one or more HF datasets using the AST AudioSet model.
4
- Writes a ``<input>_classified`` dataset with all original rows + classification columns.
5
  The audio column is kept as-is so the HF viewer shows an audio player.
6
  """
7
 
@@ -15,51 +15,68 @@ import time
15
  from pathlib import Path
16
  from typing import Any, Generator
17
 
18
- # HF env must be set before any HF import
19
  os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
 
20
 
21
  import gradio as gr
22
  import pandas as pd
23
  import pyarrow as pa
24
  import pyarrow.parquet as pq
 
25
  from huggingface_hub import HfApi, hf_hub_download
26
 
27
- from music_detector import ChunkVerdict, judge_chunk_files_batched
28
 
29
  # ---------------------------------------------------------------------------
30
  # Constants
31
  # ---------------------------------------------------------------------------
32
 
33
- SHARD_MAX_BYTES = 260 * 1024 * 1024 # 260 MB — HF viewer limit
 
 
34
  BATCH_SIZE = 32
35
  DEFAULT_THRESHOLD = 0.25
36
  DEFAULT_NAMESPACE = "fosters"
 
37
 
38
- # PyArrow type HF dtype string
39
- _PA_TO_HF_DTYPE: dict[Any, str] = {
40
- pa.string(): "string",
41
- pa.large_string(): "string",
42
- pa.float32(): "float32",
43
- pa.float64(): "float64",
44
- pa.bool_(): "bool",
45
- pa.int8(): "int8",
46
- pa.int16(): "int16",
47
- pa.int32(): "int32",
48
- pa.int64(): "int64",
49
- }
50
-
51
- # New classifier fields added to every output shard
52
  _CLASSIFIER_FIELDS = [
53
- pa.field("music_score", pa.float32()),
54
- pa.field("has_music", pa.bool_()),
 
 
 
 
55
  pa.field("clipping_ratio", pa.float32()),
56
- pa.field("rms_db", pa.float32()),
57
- pa.field("max_silence_sec", pa.float32()),
58
- pa.field("flags", pa.string()),
59
- pa.field("top_labels", pa.string()),
60
- pa.field("contaminated", pa.bool_()),
61
  ]
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  # ---------------------------------------------------------------------------
65
  # Helpers
@@ -70,15 +87,17 @@ def _ts() -> str:
70
  return datetime.datetime.now().strftime("%H:%M:%S")
71
 
72
 
73
- def _default_out_repo(in_repo: str) -> str:
74
  parts = in_repo.split("/")
75
- name = parts[-1] if parts else in_repo
76
- ns = parts[0] if len(parts) > 1 else DEFAULT_NAMESPACE
77
- return f"{ns}/{name}_classified"
 
 
 
78
 
79
 
80
  def _list_parquet_shards(repo_id: str, token: str | None) -> list[str]:
81
- """Return sorted list of data/train-*.parquet rfilenames in the dataset repo."""
82
  api = HfApi(token=token)
83
  return sorted(
84
  f.rfilename
@@ -89,72 +108,57 @@ def _list_parquet_shards(repo_id: str, token: str | None) -> list[str]:
89
  )
90
 
91
 
92
- def _pa_field_to_hf(f: pa.Field) -> dict[str, Any]:
93
- dtype = _PA_TO_HF_DTYPE.get(f.type, str(f.type))
94
- return {"_type": "Value", "dtype": dtype}
95
-
96
-
97
- def _build_hf_features(in_schema: pa.Schema) -> dict[str, Any]:
98
- """Build HF features dict: original columns + classifier columns.
99
-
100
- Preserves existing HF metadata for original columns where available.
101
- Always ensures the 'audio' column has ``{"_type": "Audio"}``.
102
- """
103
- # Try to load existing HF features from input schema metadata
104
- existing: dict[str, Any] = {}
105
- if in_schema.metadata and b"huggingface" in in_schema.metadata:
106
- try:
107
- meta = json.loads(in_schema.metadata[b"huggingface"].decode())
108
- existing = meta.get("info", {}).get("features", {})
109
- except Exception:
110
- pass
111
-
112
- features: dict[str, Any] = {}
113
-
114
- # Original columns
115
- for f in in_schema:
116
- if f.name == "audio":
117
- features[f.name] = {"_type": "Audio"}
118
- elif f.name in existing:
119
- features[f.name] = existing[f.name]
120
- else:
121
- features[f.name] = _pa_field_to_hf(f)
122
-
123
- # Classifier columns
124
- for f in _CLASSIFIER_FIELDS:
125
- features[f.name] = _pa_field_to_hf(f)
126
-
127
- return features
128
-
129
-
130
- def _append_classifier_columns(
131
- in_table: pa.Table,
132
- verdicts: list[ChunkVerdict],
133
- ) -> pa.Table:
134
- """Return a new table with all original columns + classifier columns + HF metadata."""
135
- hf_features = _build_hf_features(in_table.schema)
136
- hf_meta = json.dumps({"info": {"features": hf_features}})
137
-
138
- new_schema = pa.schema(
139
- list(in_table.schema) + _CLASSIFIER_FIELDS,
140
- metadata={"huggingface": hf_meta},
141
  )
142
 
143
- data: dict[str, Any] = {name: in_table.column(name) for name in in_table.column_names}
144
- data["music_score"] = pa.array([v.music_score for v in verdicts], type=pa.float32())
145
- data["has_music"] = pa.array([v.has_music for v in verdicts], type=pa.bool_())
146
- data["clipping_ratio"] = pa.array([v.clipping_ratio for v in verdicts], type=pa.float32())
147
- data["rms_db"] = pa.array([v.rms_db for v in verdicts], type=pa.float32())
148
- data["max_silence_sec"] = pa.array([v.max_silence_sec for v in verdicts], type=pa.float32())
149
- data["flags"] = pa.array([",".join(v.flags) for v in verdicts], type=pa.string())
150
- data["top_labels"] = pa.array([", ".join(v.top_labels[:3]) for v in verdicts], type=pa.string())
151
- data["contaminated"] = pa.array([v.contaminated for v in verdicts], type=pa.bool_())
152
 
153
- return pa.table(data, schema=new_schema)
 
 
 
 
154
 
155
 
156
  # ---------------------------------------------------------------------------
157
- # Per-repo processing
158
  # ---------------------------------------------------------------------------
159
 
160
 
@@ -164,22 +168,35 @@ def _process_repo(
164
  music_threshold: float,
165
  private: bool,
166
  token: str | None,
167
- log: list[str],
168
- ) -> dict[str, Any]:
169
- """Download, classify, and push one repo. Returns per-repo stats dict."""
170
- api = HfApi(token=token)
 
 
 
171
  short = repo_id.split("/")[-1]
172
 
173
- log.append(f"[{_ts()}] {short}: listing shards…")
174
- shard_names = _list_parquet_shards(repo_id, token)
 
 
 
 
 
 
175
  if not shard_names:
176
- log.append(f"[{_ts()}] {short}: ✗ no parquet shards found under data/")
177
- return {"repo": repo_id, "error": "no shards"}
 
178
 
179
- log.append(f"[{_ts()}] {short}: {len(shard_names)} shard(s) found")
180
  api.create_repo(repo_id=out_repo, repo_type="dataset", exist_ok=True, private=private)
181
 
182
- stats = {"total": 0, "contaminated": 0, "has_music": 0, "technical": 0}
 
 
 
183
 
184
  with tempfile.TemporaryDirectory() as tmp:
185
  out_data_dir = Path(tmp) / "data"
@@ -187,67 +204,112 @@ def _process_repo(
187
  out_parts: list[Path] = []
188
 
189
  for shard_idx, shard_name in enumerate(shard_names):
190
- log.append(
191
- f"[{_ts()}] {short}: shard {shard_idx + 1}/{len(shard_names)}"
192
- f" downloading…"
193
- )
194
- shard_path = hf_hub_download(
195
- repo_id=repo_id, filename=shard_name,
196
- repo_type="dataset", token=token,
197
- )
198
- in_table = pq.read_table(shard_path)
199
- n_rows = len(in_table)
200
- log.append(f"[{_ts()}] {short}: shard has {n_rows} rows — classifying…")
201
-
202
- # Extract audio bytes + classify in a temp dir
203
- with tempfile.TemporaryDirectory() as audio_tmp:
204
- audio_files: list[Path] = []
205
- audio_col = in_table.column("audio")
206
- for i in range(n_rows):
207
- row_audio = audio_col[i].as_py()
208
- audio_bytes: bytes = row_audio.get("bytes") or b""
209
- suffix = Path(row_audio.get("path") or "chunk.mp3").suffix or ".mp3"
210
- audio_path = Path(audio_tmp) / f"chunk_{i:06d}{suffix}"
211
- audio_path.write_bytes(audio_bytes)
212
- audio_files.append(audio_path)
213
-
214
- t0 = time.time()
215
- verdicts = judge_chunk_files_batched(
216
- audio_files,
217
- music_threshold=music_threshold,
218
- batch_size=BATCH_SIZE,
219
- device="cpu",
220
  )
221
- elapsed = int(time.time() - t0)
222
- n_cont = sum(v.contaminated for v in verdicts)
223
- n_music = sum(v.has_music for v in verdicts)
224
- n_tech = sum(bool(v.flags) and not v.has_music for v in verdicts)
225
- log.append(
226
- f"[{_ts()}] {short}: shard done in {elapsed}s — "
227
- f"contaminated={n_cont}/{n_rows} "
228
- f"(music={n_music}, technical={n_tech})"
229
- )
230
-
231
- # Build output shard
232
- out_table = _append_classifier_columns(in_table, verdicts)
233
- out_part = out_data_dir / f"part_{shard_idx:05d}.parquet"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  pq.write_table(out_table, out_part, row_group_size=1, compression="snappy")
235
  out_parts.append(out_part)
 
 
 
 
236
 
237
- stats["total"] += n_rows
238
- stats["contaminated"] += n_cont
239
- stats["has_music"] += n_music
240
- stats["technical"] += n_tech
241
-
242
- # Rename parts with final shard count and upload
243
  n_shards = len(out_parts)
244
- final_parts: list[Path] = []
245
  for i, p in enumerate(out_parts):
246
- final = out_data_dir / f"train-{i:05d}-of-{n_shards:05d}.parquet"
247
- p.rename(final)
248
- final_parts.append(final)
249
 
250
- log.append(f"[{_ts()}] {short}: uploading {n_shards} shard(s) → {out_repo}…")
 
 
251
  api.upload_folder(
252
  folder_path=str(out_data_dir),
253
  repo_id=out_repo,
@@ -260,13 +322,10 @@ def _process_repo(
260
  f"contaminated={stats['contaminated']})"
261
  ),
262
  )
263
-
264
- log.append(
265
- f"[{_ts()}] {short}: ✓ done → https://huggingface.co/datasets/{out_repo}"
266
- )
267
- stats["repo"] = repo_id
268
- stats["out_repo"] = out_repo
269
- return stats
270
 
271
 
272
  # ---------------------------------------------------------------------------
@@ -292,94 +351,108 @@ def classify_repos(
292
  if r and "/" in r
293
  ]
294
  if not repos:
295
- yield "No valid repo IDs found (expected owner/name format).", pd.DataFrame(), ""
296
  return
297
 
298
- suffix = out_suffix.strip() or "_classified"
299
- if not suffix.startswith("_"):
300
- suffix = "_" + suffix
301
-
302
- log: list[str] = [
303
- f"[{_ts()}] Starting: {len(repos)} repo(s), threshold={music_threshold:.2f}",
304
- f"[{_ts()}] Loading AST model (MIT/ast-finetuned-audioset-10-10-0.4593)…",
305
- ]
306
- yield "\n".join(log), pd.DataFrame(), ""
307
-
308
- # Warm up the model before processing
309
- progress(0.0, desc="Loading model…")
 
 
 
 
 
 
310
  try:
311
- from music_detector import _get_ast_runtime
312
  _get_ast_runtime("cpu")
313
- log.append(f"[{_ts()}] Model loaded ✓")
314
  except Exception as exc:
315
- log.append(f"[{_ts()}] ✗ Model load failed: {exc}")
316
- yield "\n".join(log), pd.DataFrame(), ""
317
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
- yield "\n".join(log), pd.DataFrame(), ""
320
-
321
- summary_rows: list[dict[str, Any]] = []
322
- links: list[str] = []
323
-
324
- for i, repo_id in enumerate(repos):
325
- out_repo = _default_out_repo(repo_id)[:-len("_classified")] + suffix
326
- progress(
327
- (i + 0.1) / len(repos),
328
- desc=f"[{i+1}/{len(repos)}] {repo_id.split('/')[-1]}",
329
- )
330
- log.append(f"\n[{_ts()}] ── Processing {i+1}/{len(repos)}: {repo_id} ──")
331
- yield "\n".join(log), pd.DataFrame(summary_rows) if summary_rows else pd.DataFrame(), ""
332
-
333
- try:
334
- stats = _process_repo(
335
- repo_id=repo_id,
336
- out_repo=out_repo,
337
- music_threshold=music_threshold,
338
- private=private,
339
- token=token,
340
- log=log,
341
  )
342
- if "error" not in stats:
343
- total = stats["total"]
344
- contaminated = stats["contaminated"]
345
- pct = 100 * contaminated / total if total else 0
346
- summary_rows.append({
347
- "repo": repo_id.split("/")[-1],
348
- "chunks": total,
349
- "contaminated": contaminated,
350
- "contam_%": f"{pct:.1f}",
351
- "music": stats["has_music"],
352
- "technical": stats["technical"],
353
- "output": out_repo,
354
- })
355
- links.append(
356
- f"- [{out_repo}](https://huggingface.co/datasets/{out_repo})"
357
- )
358
- else:
359
- summary_rows.append({
360
- "repo": repo_id.split("/")[-1],
361
- "chunks": 0, "contaminated": 0, "contam_%": "—",
362
- "music": 0, "technical": 0,
363
- "output": f"ERROR: {stats['error']}",
364
- })
365
- except Exception as exc:
366
- log.append(f"[{_ts()}] ✗ {repo_id}: {exc}")
367
  summary_rows.append({
368
- "repo": repo_id.split("/")[-1],
369
- "chunks": 0, "contaminated": 0, "contam_%": "—",
370
- "music": 0, "technical": 0,
371
- "output": f"ERROR: {exc}",
372
  })
 
373
 
374
- progress((i + 1) / len(repos), desc=f"Done {i+1}/{len(repos)}")
375
- summary_df = pd.DataFrame(summary_rows)
376
- links_md = "## Output datasets\n" + "\n".join(links) if links else ""
377
- yield "\n".join(log), summary_df, links_md
378
 
379
- log.append(f"\n[{_ts()}] === All done ===")
380
- summary_df = pd.DataFrame(summary_rows)
381
- links_md = "## Output datasets\n" + "\n".join(links) if links else ""
382
- yield "\n".join(log), summary_df, links_md
383
 
384
 
385
  # ---------------------------------------------------------------------------
@@ -389,20 +462,14 @@ def classify_repos(
389
  DESCRIPTION = """
390
  # 🎵 Chunk Classifier
391
 
392
- Scores audio chunks for **music contamination** and **technical defects** using the
393
- [AST AudioSet model](https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593)
394
- (527 classes, multi-label sigmoid).
395
 
396
- **Input:** chunk dataset(s) produced by the AudioSet pipeline
397
- (`fosters/some-book-chunks`, one per line).
398
 
399
- **Output:** `<input>_classified` all rows kept, new columns added:
400
- `music_score · has_music · contaminated · flags · top_labels · rms_db · clipping_ratio · max_silence_sec`
401
-
402
- The `audio` column is unchanged → the HF viewer shows an audio player.
403
- **Sort by `music_score` descending to quickly review suspicious chunks.**
404
-
405
- ---
406
  """
407
 
408
  with gr.Blocks(title="Chunk Classifier") as demo:
@@ -412,43 +479,41 @@ with gr.Blocks(title="Chunk Classifier") as demo:
412
  with gr.Column(scale=2):
413
  repos_input = gr.Textbox(
414
  label="Dataset repo IDs (one per line)",
415
- placeholder="fosters/my-audiobook-chunks\nfosters/another-book-chunks",
416
- lines=8,
417
  )
418
  with gr.Column(scale=1):
419
  out_suffix = gr.Textbox(
420
- label="Output suffix",
421
- value="_classified",
422
  info="Appended to each input repo name",
423
  )
424
  threshold = gr.Slider(
425
  0.10, 0.60, value=DEFAULT_THRESHOLD, step=0.05,
426
  label="Music threshold",
427
- info="Lower = more sensitive (flag more). Biased to recall.",
428
- )
429
- private_toggle = gr.Checkbox(
430
- label="Private output datasets",
431
- value=True,
432
  )
433
- token_input = gr.Textbox(
 
434
  label="HF Token (or set HF_TOKEN secret)",
435
- type="password",
436
- placeholder="hf_…",
437
  )
438
  run_btn = gr.Button("▶ Classify", variant="primary", size="lg")
439
 
440
- log_out = gr.Textbox(
441
- label="Progress log",
442
- interactive=False,
443
- lines=18,
444
- max_lines=100,
445
- )
446
- summary_out = gr.Dataframe(
447
- label="Per-repo summary",
448
- headers=["repo", "chunks", "contaminated", "contam_%", "music", "technical", "output"],
449
- wrap=True,
450
- )
451
- links_out = gr.Markdown()
 
 
 
452
 
453
  run_btn.click(
454
  classify_repos,
 
1
  """Chunk Classifier — HF Space.
2
 
3
  Classifies audio chunks in one or more HF datasets using the AST AudioSet model.
4
+ Writes a ``<input>_classified`` dataset with id + audio + classification columns only.
5
  The audio column is kept as-is so the HF viewer shows an audio player.
6
  """
7
 
 
15
  from pathlib import Path
16
  from typing import Any, Generator
17
 
18
+ # Must be set before any HF import
19
  os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
20
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "120")
21
 
22
  import gradio as gr
23
  import pandas as pd
24
  import pyarrow as pa
25
  import pyarrow.parquet as pq
26
+ import torch
27
  from huggingface_hub import HfApi, hf_hub_download
28
 
29
+ from music_detector import ChunkVerdict, _get_ast_runtime, judge_chunk_files_batched
30
 
31
  # ---------------------------------------------------------------------------
32
  # Constants
33
  # ---------------------------------------------------------------------------
34
 
35
+ N_CPUS = os.cpu_count() or 2
36
+ torch.set_num_threads(N_CPUS) # let BLAS use all CPUs for AST inference
37
+
38
  BATCH_SIZE = 32
39
  DEFAULT_THRESHOLD = 0.25
40
  DEFAULT_NAMESPACE = "fosters"
41
+ LOG_MAX_LINES = 500 # keep only last N lines in the log box
42
 
43
+ # New classifier columns added to every output shard
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  _CLASSIFIER_FIELDS = [
45
+ pa.field("music_score", pa.float32()),
46
+ pa.field("has_music", pa.bool_()),
47
+ pa.field("contaminated", pa.bool_()),
48
+ pa.field("flags", pa.string()),
49
+ pa.field("top_labels", pa.string()),
50
+ pa.field("rms_db", pa.float32()),
51
  pa.field("clipping_ratio", pa.float32()),
52
+ pa.field("max_silence_sec",pa.float32()),
 
 
 
 
53
  ]
54
 
55
+ _HF_FEATURES_CLASSIFIER = {
56
+ "id": {"_type": "Value", "dtype": "string"},
57
+ "audio": {"_type": "Audio"},
58
+ "music_score": {"_type": "Value", "dtype": "float32"},
59
+ "has_music": {"_type": "Value", "dtype": "bool"},
60
+ "contaminated": {"_type": "Value", "dtype": "bool"},
61
+ "flags": {"_type": "Value", "dtype": "string"},
62
+ "top_labels": {"_type": "Value", "dtype": "string"},
63
+ "rms_db": {"_type": "Value", "dtype": "float32"},
64
+ "clipping_ratio": {"_type": "Value", "dtype": "float32"},
65
+ "max_silence_sec":{"_type": "Value", "dtype": "float32"},
66
+ }
67
+
68
+ _OUTPUT_SCHEMA = pa.schema(
69
+ [
70
+ pa.field("id", pa.string()),
71
+ pa.field("audio", pa.struct([
72
+ pa.field("bytes", pa.binary()),
73
+ pa.field("path", pa.string()),
74
+ ])),
75
+ *_CLASSIFIER_FIELDS,
76
+ ],
77
+ metadata={"huggingface": json.dumps({"info": {"features": _HF_FEATURES_CLASSIFIER}})},
78
+ )
79
+
80
 
81
  # ---------------------------------------------------------------------------
82
  # Helpers
 
87
  return datetime.datetime.now().strftime("%H:%M:%S")
88
 
89
 
90
+ def _default_out_repo(in_repo: str, suffix: str) -> str:
91
  parts = in_repo.split("/")
92
+ name = parts[-1]
93
+ ns = parts[0] if len(parts) > 1 else DEFAULT_NAMESPACE
94
+ suf = suffix.strip()
95
+ if not suf.startswith("_"):
96
+ suf = "_" + suf
97
+ return f"{ns}/{name}{suf}"
98
 
99
 
100
  def _list_parquet_shards(repo_id: str, token: str | None) -> list[str]:
 
101
  api = HfApi(token=token)
102
  return sorted(
103
  f.rfilename
 
108
  )
109
 
110
 
111
+ def _build_output_row(
112
+ chunk_id: str,
113
+ audio_bytes: bytes,
114
+ audio_path_hint: str,
115
+ verdict: ChunkVerdict,
116
+ ) -> dict[str, Any]:
117
+ return {
118
+ "id": chunk_id,
119
+ "audio": {"bytes": audio_bytes, "path": audio_path_hint},
120
+ "music_score": verdict.music_score,
121
+ "has_music": verdict.has_music,
122
+ "contaminated": verdict.contaminated,
123
+ "flags": ", ".join(verdict.flags),
124
+ "top_labels": ", ".join(verdict.top_labels[:3]),
125
+ "rms_db": verdict.rms_db,
126
+ "clipping_ratio": verdict.clipping_ratio,
127
+ "max_silence_sec":verdict.max_silence_sec,
128
+ }
129
+
130
+
131
+ def _rows_to_table(rows: list[dict[str, Any]]) -> pa.Table:
132
+ audio_type = _OUTPUT_SCHEMA.field("audio").type
133
+ return pa.table(
134
+ {
135
+ "id": pa.array([r["id"] for r in rows], type=pa.string()),
136
+ "audio": pa.array(
137
+ [{"bytes": r["audio"]["bytes"], "path": r["audio"]["path"]} for r in rows],
138
+ type=audio_type,
139
+ ),
140
+ "music_score": pa.array([r["music_score"] for r in rows], type=pa.float32()),
141
+ "has_music": pa.array([r["has_music"] for r in rows], type=pa.bool_()),
142
+ "contaminated": pa.array([r["contaminated"] for r in rows], type=pa.bool_()),
143
+ "flags": pa.array([r["flags"] for r in rows], type=pa.string()),
144
+ "top_labels": pa.array([r["top_labels"] for r in rows], type=pa.string()),
145
+ "rms_db": pa.array([r["rms_db"] for r in rows], type=pa.float32()),
146
+ "clipping_ratio": pa.array([r["clipping_ratio"] for r in rows], type=pa.float32()),
147
+ "max_silence_sec":pa.array([r["max_silence_sec"] for r in rows], type=pa.float32()),
148
+ },
149
+ schema=_OUTPUT_SCHEMA,
 
 
 
 
 
 
 
 
 
 
150
  )
151
 
 
 
 
 
 
 
 
 
 
152
 
153
+ def _fmt_verdict(chunk_id: str, v: ChunkVerdict) -> str:
154
+ status = "🔴 MUSIC" if v.has_music else ("⚠️ TECH" if v.flags else "✅ ok")
155
+ labels = ", ".join(v.top_labels[:2])
156
+ flags = f" [{', '.join(v.flags)}]" if v.flags else ""
157
+ return f" {chunk_id}: score={v.music_score:.3f} {labels}{flags} {status}"
158
 
159
 
160
  # ---------------------------------------------------------------------------
161
+ # Per-repo generator
162
  # ---------------------------------------------------------------------------
163
 
164
 
 
168
  music_threshold: float,
169
  private: bool,
170
  token: str | None,
171
+ ) -> Generator[tuple[list[str], dict[str, Any] | None], None, None]:
172
+ """Generator: process one repo. Yields (log_lines, stats_or_None).
173
+
174
+ Yields after every BATCH_SIZE chunks so the UI stays responsive.
175
+ Final yield carries the completed stats dict.
176
+ """
177
+ api = HfApi(token=token)
178
  short = repo_id.split("/")[-1]
179
 
180
+ # --- list shards ---
181
+ yield [f"[{_ts()}] {short}: listing parquet shards…"], None
182
+ try:
183
+ shard_names = _list_parquet_shards(repo_id, token)
184
+ except Exception as exc:
185
+ yield [f"[{_ts()}] {short}: ✗ cannot list shards: {exc}"], {"repo": repo_id, "error": str(exc)}
186
+ return
187
+
188
  if not shard_names:
189
+ msg = f"[{_ts()}] {short}: ✗ no data/train-*.parquet shards found"
190
+ yield [msg], {"repo": repo_id, "error": "no shards"}
191
+ return
192
 
193
+ yield [f"[{_ts()}] {short}: {len(shard_names)} shard(s) — creating output repo…"], None
194
  api.create_repo(repo_id=out_repo, repo_type="dataset", exist_ok=True, private=private)
195
 
196
+ stats: dict[str, Any] = {
197
+ "repo": repo_id, "out_repo": out_repo,
198
+ "total": 0, "contaminated": 0, "has_music": 0, "technical": 0,
199
+ }
200
 
201
  with tempfile.TemporaryDirectory() as tmp:
202
  out_data_dir = Path(tmp) / "data"
 
204
  out_parts: list[Path] = []
205
 
206
  for shard_idx, shard_name in enumerate(shard_names):
207
+ # --- download shard ---
208
+ yield [f"[{_ts()}] {short}: shard {shard_idx+1}/{len(shard_names)} — downloading…"], None
209
+ t_shard = time.time()
210
+ try:
211
+ shard_path = hf_hub_download(
212
+ repo_id=repo_id, filename=shard_name,
213
+ repo_type="dataset", token=token,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  )
215
+ except Exception as exc:
216
+ yield [f"[{_ts()}] {short}: download failed: {exc}"], None
217
+ continue
218
+
219
+ in_table = pq.read_table(shard_path)
220
+ n_rows = len(in_table)
221
+ audio_col = in_table.column("audio")
222
+ id_col = in_table.column("id").to_pylist()
223
+
224
+ yield [
225
+ f"[{_ts()}] {short}: shard {shard_idx+1}/{len(shard_names)} — "
226
+ f"{n_rows} rows downloaded in {time.time()-t_shard:.1f}s, classifying…"
227
+ ], None
228
+
229
+ n_batches = (n_rows + BATCH_SIZE - 1) // BATCH_SIZE
230
+ shard_rows: list[dict[str, Any]] = []
231
+
232
+ for batch_idx in range(n_batches):
233
+ b_start = batch_idx * BATCH_SIZE
234
+ b_end = min(b_start + BATCH_SIZE, n_rows)
235
+ t_batch = time.time()
236
+
237
+ batch_ids: list[str] = id_col[b_start:b_end]
238
+ batch_audio_bytes: list[bytes] = [
239
+ (audio_col[i].as_py() or {}).get("bytes") or b""
240
+ for i in range(b_start, b_end)
241
+ ]
242
+ batch_path_hints: list[str] = [
243
+ (audio_col[i].as_py() or {}).get("path") or "chunk.mp3"
244
+ for i in range(b_start, b_end)
245
+ ]
246
+
247
+ # write temp audio files for the batch
248
+ with tempfile.TemporaryDirectory() as audio_tmp:
249
+ audio_files: list[Path] = []
250
+ for k, (ab, ph) in enumerate(zip(batch_audio_bytes, batch_path_hints)):
251
+ suffix = Path(ph).suffix or ".mp3"
252
+ p = Path(audio_tmp) / f"chunk_{k:04d}{suffix}"
253
+ p.write_bytes(ab)
254
+ audio_files.append(p)
255
+
256
+ verdicts = judge_chunk_files_batched(
257
+ audio_files,
258
+ music_threshold=music_threshold,
259
+ batch_size=len(audio_files),
260
+ device="cpu",
261
+ )
262
+
263
+ # accumulate output rows
264
+ for chunk_id, audio_bytes, ph, v in zip(
265
+ batch_ids, batch_audio_bytes, batch_path_hints, verdicts
266
+ ):
267
+ shard_rows.append(_build_output_row(chunk_id, audio_bytes, ph, v))
268
+
269
+ # update stats
270
+ n_cont = sum(v.contaminated for v in verdicts)
271
+ n_music = sum(v.has_music for v in verdicts)
272
+ n_tech = sum(bool(v.flags) and not v.has_music for v in verdicts)
273
+ stats["total"] += b_end - b_start
274
+ stats["contaminated"]+= n_cont
275
+ stats["has_music"] += n_music
276
+ stats["technical"] += n_tech
277
+
278
+ elapsed = time.time() - t_batch
279
+
280
+ log_lines = [""] # blank separator
281
+ log_lines += [_fmt_verdict(cid, v) for cid, v in zip(batch_ids, verdicts)]
282
+ log_lines += [
283
+ f"[{_ts()}] batch {batch_idx+1}/{n_batches} "
284
+ f"({b_end - b_start} chunks, {elapsed:.1f}s) "
285
+ f"shard {shard_idx+1}/{len(shard_names)} "
286
+ f"total {stats['total']} processed "
287
+ f"contaminated {stats['contaminated']} "
288
+ f"({100*stats['contaminated']/max(stats['total'],1):.1f}%)"
289
+ ]
290
+ yield log_lines, None
291
+
292
+ # write shard
293
+ out_table = _rows_to_table(shard_rows)
294
+ out_part = out_data_dir / f"part_{shard_idx:05d}.parquet"
295
  pq.write_table(out_table, out_part, row_group_size=1, compression="snappy")
296
  out_parts.append(out_part)
297
+ yield [
298
+ f"[{_ts()}] {short}: shard {shard_idx+1} written "
299
+ f"({len(shard_rows)} rows, {out_part.stat().st_size//1024} KB)"
300
+ ], None
301
 
302
+ # rename with final shard count
 
 
 
 
 
303
  n_shards = len(out_parts)
304
+ final: list[Path] = []
305
  for i, p in enumerate(out_parts):
306
+ dst = out_data_dir / f"train-{i:05d}-of-{n_shards:05d}.parquet"
307
+ p.rename(dst)
308
+ final.append(dst)
309
 
310
+ # upload
311
+ yield [f"[{_ts()}] {short}: uploading {n_shards} shard(s) → {out_repo}…"], None
312
+ t_up = time.time()
313
  api.upload_folder(
314
  folder_path=str(out_data_dir),
315
  repo_id=out_repo,
 
322
  f"contaminated={stats['contaminated']})"
323
  ),
324
  )
325
+ yield [
326
+ f"[{_ts()}] {short}: ✓ uploaded in {time.time()-t_up:.1f}s "
327
+ f"→ https://huggingface.co/datasets/{out_repo}"
328
+ ], stats
 
 
 
329
 
330
 
331
  # ---------------------------------------------------------------------------
 
351
  if r and "/" in r
352
  ]
353
  if not repos:
354
+ yield "No valid repo IDs (expected owner/name format).", pd.DataFrame(), ""
355
  return
356
 
357
+ log: list[str] = []
358
+ summary_rows: list[dict[str, Any]] = []
359
+ links: list[str] = []
360
+
361
+ def _emit() -> tuple[str, pd.DataFrame, str]:
362
+ log_text = "\n".join(log[-LOG_MAX_LINES:])
363
+ df = pd.DataFrame(summary_rows) if summary_rows else pd.DataFrame()
364
+ links_md = "## Output datasets\n" + "\n".join(links) if links else ""
365
+ return log_text, df, links_md
366
+
367
+ log.append(f"[{_ts()}] Starting: {len(repos)} repo(s), threshold={music_threshold:.2f}")
368
+ log.append(f"[{_ts()}] CPUs={N_CPUS}, BATCH_SIZE={BATCH_SIZE}, "
369
+ f"HF_XET={'1' if os.environ.get('HF_XET_HIGH_PERFORMANCE') else 'off'}")
370
+
371
+ # Warm up model
372
+ progress(0.0, desc="Loading AST model…")
373
+ log.append(f"[{_ts()}] Loading AST model (MIT/ast-finetuned-audioset-10-10-0.4593)…")
374
+ yield _emit()
375
  try:
 
376
  _get_ast_runtime("cpu")
377
+ log.append(f"[{_ts()}] Model ready ✓")
378
  except Exception as exc:
379
+ log.append(f"[{_ts()}] ✗ model load failed: {exc}")
380
+ yield _emit()
381
  return
382
+ yield _emit()
383
+
384
+ t_total = time.time()
385
+
386
+ for repo_idx, repo_id in enumerate(repos):
387
+ out_repo = _default_out_repo(repo_id, out_suffix)
388
+ log.append(f"\n[{_ts()}] {'─'*60}")
389
+ log.append(f"[{_ts()}] [{repo_idx+1}/{len(repos)}] {repo_id}")
390
+ log.append(f"[{_ts()}] output → {out_repo}")
391
+ yield _emit()
392
+
393
+ t_repo = time.time()
394
+ final_stats: dict[str, Any] | None = None
395
+
396
+ for new_lines, maybe_stats in _process_repo(
397
+ repo_id=repo_id,
398
+ out_repo=out_repo,
399
+ music_threshold=music_threshold,
400
+ private=private,
401
+ token=token,
402
+ ):
403
+ log.extend(new_lines)
404
+ if maybe_stats is not None:
405
+ final_stats = maybe_stats
406
+
407
+ # Update progress bar (rough estimate by chunk count)
408
+ total_done = sum(r.get("total", 0) for r in summary_rows)
409
+ if final_stats:
410
+ total_done += final_stats.get("total", 0)
411
+ progress(
412
+ (repo_idx + min(
413
+ (final_stats or {}).get("total", 0) / max(
414
+ (final_stats or {}).get("total", 1), 1
415
+ ), 1.0
416
+ )) / len(repos),
417
+ desc=f"[{repo_idx+1}/{len(repos)}] {repo_id.split('/')[-1]}",
418
+ )
419
+ yield _emit()
420
 
421
+ # Build summary row
422
+ if final_stats and "error" not in final_stats:
423
+ total = final_stats["total"]
424
+ cont = final_stats["contaminated"]
425
+ summary_rows.append({
426
+ "repo": repo_id.split("/")[-1],
427
+ "chunks": total,
428
+ "contam_%": f"{100*cont/max(total,1):.1f}",
429
+ "music": final_stats["has_music"],
430
+ "technical": final_stats["technical"],
431
+ "time_s": f"{time.time()-t_repo:.0f}",
432
+ "output": out_repo,
433
+ })
434
+ links.append(f"- [{out_repo}](https://huggingface.co/datasets/{out_repo})")
435
+ log.append(
436
+ f"[{_ts()}] {repo_id.split('/')[-1]}: DONE "
437
+ f"total={total} contaminated={cont} ({100*cont/max(total,1):.1f}%) "
438
+ f"music={final_stats['has_music']} technical={final_stats['technical']} "
439
+ f"elapsed={time.time()-t_repo:.0f}s"
 
 
 
440
  )
441
+ elif final_stats:
442
+ err = final_stats.get("error", "unknown error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  summary_rows.append({
444
+ "repo": repo_id.split("/")[-1], "chunks": 0,
445
+ "contam_%": "—", "music": 0, "technical": 0, "time_s": "—",
446
+ "output": f"ERROR: {err}",
 
447
  })
448
+ log.append(f"[{_ts()}] {repo_id.split('/')[-1]}: ✗ {err}")
449
 
450
+ progress((repo_idx + 1) / len(repos), desc=f"Done {repo_idx+1}/{len(repos)}")
451
+ yield _emit()
 
 
452
 
453
+ log.append(f"\n[{_ts()}] {'='*60}")
454
+ log.append(f"[{_ts()}] All done in {time.time()-t_total:.0f}s")
455
+ yield _emit()
 
456
 
457
 
458
  # ---------------------------------------------------------------------------
 
462
  DESCRIPTION = """
463
  # 🎵 Chunk Classifier
464
 
465
+ Scores audio chunks for **music contamination** and **technical defects** using
466
+ [MIT/ast-finetuned-audioset-10-10-0.4593](https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593)
467
+ (527 AudioSet classes, multi-label sigmoid).
468
 
469
+ **Input** chunk dataset repo IDs (one per line, `owner/name` format).
470
+ **Output** — `<input>_classified` with: `id · audio · music_score · has_music · contaminated · flags · top_labels · rms_db · clipping_ratio · max_silence_sec`.
471
 
472
+ Sort by `music_score` in the HF viewer and press play to spot-check flagged chunks.
 
 
 
 
 
 
473
  """
474
 
475
  with gr.Blocks(title="Chunk Classifier") as demo:
 
479
  with gr.Column(scale=2):
480
  repos_input = gr.Textbox(
481
  label="Dataset repo IDs (one per line)",
482
+ placeholder="fosters/my-book-chunks\nfosters/another-book-chunks",
483
+ lines=6,
484
  )
485
  with gr.Column(scale=1):
486
  out_suffix = gr.Textbox(
487
+ label="Output suffix", value="_classified",
 
488
  info="Appended to each input repo name",
489
  )
490
  threshold = gr.Slider(
491
  0.10, 0.60, value=DEFAULT_THRESHOLD, step=0.05,
492
  label="Music threshold",
493
+ info="Lower = more sensitive. Default 0.25 biased to recall.",
 
 
 
 
494
  )
495
+ private_toggle = gr.Checkbox(label="Private output", value=True)
496
+ token_input = gr.Textbox(
497
  label="HF Token (or set HF_TOKEN secret)",
498
+ type="password", placeholder="hf_…",
 
499
  )
500
  run_btn = gr.Button("▶ Classify", variant="primary", size="lg")
501
 
502
+ with gr.Row():
503
+ with gr.Column(scale=3):
504
+ log_out = gr.Textbox(
505
+ label="Processing log (per-chunk detail, updates after every batch)",
506
+ interactive=False,
507
+ lines=30,
508
+ max_lines=60,
509
+ )
510
+ with gr.Column(scale=2):
511
+ summary_out = gr.Dataframe(
512
+ label="Per-repo summary",
513
+ headers=["repo", "chunks", "contam_%", "music", "technical", "time_s", "output"],
514
+ wrap=True,
515
+ )
516
+ links_out = gr.Markdown()
517
 
518
  run_btn.click(
519
  classify_repos,