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

Phase 1: chunk music/quality classifier Space

Browse files
Files changed (4) hide show
  1. README.md +13 -6
  2. app.py +460 -0
  3. music_detector.py +346 -0
  4. requirements.txt +8 -0
README.md CHANGED
@@ -1,13 +1,20 @@
1
  ---
2
  title: Chunk Classifier
3
- emoji: 💻
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Chunk Classifier
3
+ emoji: 🎵
4
+ colorFrom: purple
5
+ colorTo: pink
6
  sdk: gradio
 
 
7
  app_file: app.py
8
  pinned: false
9
  ---
10
 
11
+ Classify audio chunks for music contamination and technical defects using the
12
+ [AST AudioSet](https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593) model (527 classes).
13
+
14
+ **Input:** one or more HF chunk dataset repo IDs (e.g. `fosters/my-book-chunks`).
15
+
16
+ **Output:** `<input>_classified` dataset — all rows kept, new columns added:
17
+ `music_score`, `has_music`, `contaminated`, `flags`, `top_labels`, `rms_db`, `clipping_ratio`, `max_silence_sec`.
18
+ The `audio` column is preserved as-is so you can listen in the HF viewer and sort by `music_score`.
19
+
20
+ Set `HF_TOKEN` as a Space secret to read private datasets and write to `fosters/`.
app.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import json
12
+ import os
13
+ import tempfile
14
+ 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
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ 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
85
+ for f in api.list_repo_tree(repo_id, repo_type="dataset", recursive=True)
86
+ if hasattr(f, "rfilename")
87
+ and f.rfilename.startswith("data/train-")
88
+ and f.rfilename.endswith(".parquet")
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
+
161
+ def _process_repo(
162
+ repo_id: str,
163
+ out_repo: str,
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"
186
+ out_data_dir.mkdir()
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,
254
+ repo_type="dataset",
255
+ path_in_repo="data/",
256
+ delete_patterns=["data/*.parquet"],
257
+ commit_message=(
258
+ f"Classify {stats['total']} chunks "
259
+ f"(threshold={music_threshold:.2f}, "
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
+ # ---------------------------------------------------------------------------
273
+ # Gradio handler
274
+ # ---------------------------------------------------------------------------
275
+
276
+
277
+ def classify_repos(
278
+ repos_text: str,
279
+ out_suffix: str,
280
+ music_threshold: float,
281
+ private: bool,
282
+ hf_token_input: str,
283
+ progress: gr.Progress = gr.Progress(),
284
+ ) -> Generator[tuple[str, pd.DataFrame, str], None, None]:
285
+ """Main Gradio generator. Yields (log_text, summary_df, links_md)."""
286
+ token = hf_token_input.strip() or os.environ.get("HF_TOKEN") or None
287
+
288
+ repos = [
289
+ r.strip()
290
+ for line in repos_text.replace(",", "\n").splitlines()
291
+ for r in [line.strip()]
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
+ # ---------------------------------------------------------------------------
386
+ # UI
387
+ # ---------------------------------------------------------------------------
388
+
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:
409
+ gr.Markdown(DESCRIPTION)
410
+
411
+ with gr.Row():
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,
455
+ inputs=[repos_input, out_suffix, threshold, private_toggle, token_input],
456
+ outputs=[log_out, summary_out, links_out],
457
+ )
458
+
459
+ demo.queue()
460
+ demo.launch(server_name="0.0.0.0")
music_detector.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Music and audio quality detector based on the AST AudioSet model.
2
+
3
+ Self-contained: only torch, transformers, numpy, and ffmpeg (subprocess) at runtime.
4
+ All torch/transformers imports are lazy so this module can be imported without
5
+ those deps installed — they only need to be present at call time.
6
+
7
+ Main API
8
+ --------
9
+ judge_chunk_files_batched(paths, *, music_threshold, batch_size, device)
10
+ -> list[ChunkVerdict]
11
+
12
+ Pure helpers (_split_samples_into_windows, _music_score_from_probs, …)
13
+ are top-level so they can be unit-tested without a model.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import re
20
+ import subprocess
21
+ import threading
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+
30
+ MODEL_ID = "MIT/ast-finetuned-audioset-10-10-0.4593"
31
+ SAMPLE_RATE = 16_000
32
+ # AST native input length (~10.24 s at 16 kHz)
33
+ MAX_CHUNK_SAMPLES = int(10.24 * SAMPLE_RATE) # 163_840
34
+
35
+ MUSIC_CLASSES = {
36
+ "Music",
37
+ "Musical instrument",
38
+ "Singing",
39
+ "Choir",
40
+ "Piano",
41
+ "Guitar",
42
+ "Drum",
43
+ "Jingle (music)",
44
+ }
45
+
46
+ _SILENCE_START_RE = re.compile(r"silence_start:\s*([0-9]+(?:\.[0-9]+)?)")
47
+ _SILENCE_END_RE = re.compile(r"silence_end:\s*([0-9]+(?:\.[0-9]+)?)")
48
+
49
+ _AST_LOCK = threading.Lock()
50
+ # (feature_extractor, model, music_indices)
51
+ _AST_RUNTIME: tuple[Any, Any, list[int]] | None = None
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Result type
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ @dataclass
60
+ class ChunkVerdict:
61
+ music_score: float
62
+ has_music: bool
63
+ clipping_ratio: float | None
64
+ rms_db: float | None
65
+ max_silence_sec: float | None
66
+ flags: list[str] = field(default_factory=list) # ["clipping","long_silence","low_loudness"]
67
+ top_labels: list[str] = field(default_factory=list) # top AST labels for transparency
68
+ contaminated: bool = False # has_music OR any flag
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Lazy model runtime (mirrors silero_vad._get_silero_runtime pattern)
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def _get_ast_runtime(device: str = "cpu") -> tuple[Any, Any, list[int]]:
77
+ """Load model + feature extractor once; cache in module global."""
78
+ global _AST_RUNTIME
79
+ with _AST_LOCK:
80
+ if _AST_RUNTIME is None:
81
+ transformers = importlib.import_module("transformers")
82
+ AutoFE = getattr(transformers, "AutoFeatureExtractor")
83
+ AutoModel = getattr(transformers, "AutoModelForAudioClassification")
84
+
85
+ fe = AutoFE.from_pretrained(MODEL_ID)
86
+ model = AutoModel.from_pretrained(MODEL_ID)
87
+ model.to(device).eval()
88
+
89
+ # Resolve music class indices by label name — never hardcode ints
90
+ music_indices = [
91
+ i for i, label in model.config.id2label.items()
92
+ if label in MUSIC_CLASSES
93
+ ]
94
+ if not music_indices:
95
+ raise RuntimeError(
96
+ f"None of MUSIC_CLASSES matched in {MODEL_ID} id2label. "
97
+ "Check that this model has AudioSet labels."
98
+ )
99
+ _AST_RUNTIME = (fe, model, music_indices)
100
+ return _AST_RUNTIME
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Audio decode (ffmpeg, same approach as quality_exp/layer1/audio_probe.py)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _decode_mono16k(path: str | Path) -> np.ndarray | None:
109
+ """Decode any audio file to mono float32 at SAMPLE_RATE Hz via ffmpeg.
110
+
111
+ Returns None on any error (bad file, ffmpeg missing, timeout).
112
+ """
113
+ try:
114
+ cmd = [
115
+ "ffmpeg", "-i", str(path),
116
+ "-f", "f32le", "-ac", "1", "-ar", str(SAMPLE_RATE),
117
+ "-v", "quiet", "-",
118
+ ]
119
+ result = subprocess.run(cmd, capture_output=True, timeout=60)
120
+ if result.returncode != 0 or not result.stdout:
121
+ return None
122
+ return np.frombuffer(result.stdout, dtype=np.float32).copy()
123
+ except Exception:
124
+ return None
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Pure helpers — testable without a model
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _split_samples_into_windows(samples: np.ndarray) -> list[np.ndarray]:
133
+ """Return 1 or 2 AST-compatible windows from a chunk.
134
+
135
+ Chunks ≤ MAX_CHUNK_SAMPLES → one window.
136
+ Longer chunks → beginning window + end window (may overlap).
137
+ """
138
+ if len(samples) <= MAX_CHUNK_SAMPLES:
139
+ return [samples]
140
+ return [samples[:MAX_CHUNK_SAMPLES], samples[-MAX_CHUNK_SAMPLES:]]
141
+
142
+
143
+ def _music_score_from_probs(probs: np.ndarray, music_indices: list[int]) -> float:
144
+ """Max sigmoid probability across music class indices."""
145
+ if not music_indices:
146
+ return 0.0
147
+ return float(probs[music_indices].max())
148
+
149
+
150
+ def _top_labels_from_probs(
151
+ probs: np.ndarray,
152
+ id2label: dict[int, str],
153
+ k: int = 5,
154
+ ) -> list[str]:
155
+ """Top-k label names by descending probability."""
156
+ top_idx = probs.argsort()[-k:][::-1]
157
+ return [id2label[int(i)] for i in top_idx if int(i) in id2label]
158
+
159
+
160
+ def _probe_duration(path: str | Path) -> float | None:
161
+ """Return audio duration in seconds via ffprobe, or None on error."""
162
+ try:
163
+ cmd = [
164
+ "ffprobe", "-v", "error",
165
+ "-show_entries", "format=duration",
166
+ "-of", "default=noprint_wrappers=1:nokey=1",
167
+ str(path),
168
+ ]
169
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
170
+ return float(result.stdout.strip())
171
+ except Exception:
172
+ return None
173
+
174
+
175
+ def _compute_max_silence(path: str | Path) -> float | None:
176
+ """Return longest silence gap in seconds via ffmpeg silencedetect, or None on error.
177
+
178
+ Handles trailing silence (file ends while still silent) by using the file
179
+ duration as the implicit silence_end.
180
+ """
181
+ try:
182
+ # Note: do NOT use -v quiet here — it suppresses silencedetect filter messages.
183
+ # Use -hide_banner + -nostats to keep stderr clean while preserving filter output.
184
+ cmd = [
185
+ "ffmpeg", "-hide_banner", "-nostats",
186
+ "-i", str(path),
187
+ "-af", "silencedetect=noise=-35dB:d=0.5",
188
+ "-f", "null", "-",
189
+ ]
190
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
191
+ gaps: list[float] = []
192
+ pending: float | None = None
193
+ for line in result.stderr.splitlines():
194
+ m = _SILENCE_START_RE.search(line)
195
+ if m:
196
+ pending = float(m.group(1))
197
+ continue
198
+ m = _SILENCE_END_RE.search(line)
199
+ if m and pending is not None:
200
+ gap = float(m.group(1)) - pending
201
+ if gap > 0:
202
+ gaps.append(gap)
203
+ pending = None
204
+
205
+ # Trailing silence: file ended before silence_end was emitted
206
+ if pending is not None:
207
+ duration = _probe_duration(path)
208
+ if duration is not None and duration > pending:
209
+ gaps.append(duration - pending)
210
+
211
+ return float(max(gaps)) if gaps else None
212
+ except Exception:
213
+ return None
214
+
215
+
216
+ def _signal_checks(
217
+ path: str | Path,
218
+ samples: np.ndarray,
219
+ *,
220
+ clipping_threshold: float = 0.001,
221
+ loudness_threshold_db: float = -45.0,
222
+ silence_threshold_sec: float = 3.0,
223
+ ) -> tuple[float | None, float | None, float | None, list[str]]:
224
+ """Return (clipping_ratio, rms_db, max_silence_sec, flags).
225
+
226
+ flags is a list of triggered quality issues: "clipping", "low_loudness", "long_silence".
227
+ """
228
+ flags: list[str] = []
229
+
230
+ clipping_ratio: float | None = None
231
+ rms_db: float | None = None
232
+ if len(samples) > 0:
233
+ clipping_ratio = float(np.mean(np.abs(samples) > 0.99))
234
+ rms = float(np.sqrt(np.mean(samples ** 2)))
235
+ rms_db = float(20.0 * np.log10(max(rms, 1e-9)))
236
+ if clipping_ratio > clipping_threshold:
237
+ flags.append("clipping")
238
+ if rms_db < loudness_threshold_db:
239
+ flags.append("low_loudness")
240
+
241
+ max_silence_sec = _compute_max_silence(path)
242
+ if max_silence_sec is not None and max_silence_sec > silence_threshold_sec:
243
+ flags.append("long_silence")
244
+
245
+ return clipping_ratio, rms_db, max_silence_sec, flags
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Public batch API
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def judge_chunk_files_batched(
254
+ audio_paths: list[str | Path],
255
+ *,
256
+ music_threshold: float = 0.25,
257
+ batch_size: int = 32,
258
+ device: str = "cpu",
259
+ n_decode_workers: int = 4,
260
+ ) -> list[ChunkVerdict]:
261
+ """Classify a list of audio files. Returns one ChunkVerdict per file.
262
+
263
+ Processing:
264
+ 1. Parallel decode + signal checks (ffmpeg, I/O bound).
265
+ 2. Batched AST inference over all windows (compute bound, single-threaded).
266
+ """
267
+ torch = importlib.import_module("torch")
268
+ fe, model, music_indices = _get_ast_runtime(device)
269
+ id2label: dict[int, str] = model.config.id2label
270
+
271
+ n = len(audio_paths)
272
+ if n == 0:
273
+ return []
274
+
275
+ # Step 1: parallel decode + signal checks
276
+ def _process_one(path: str | Path) -> tuple[
277
+ np.ndarray | None, float | None, float | None, float | None, list[str]
278
+ ]:
279
+ samples = _decode_mono16k(path)
280
+ if samples is None:
281
+ return None, None, None, None, ["decode_error"]
282
+ clipping, rms_db, max_silence, flags = _signal_checks(path, samples)
283
+ return samples, clipping, rms_db, max_silence, flags
284
+
285
+ workers = min(n, n_decode_workers)
286
+ with ThreadPoolExecutor(max_workers=workers) as ex:
287
+ file_results = list(ex.map(_process_one, audio_paths))
288
+
289
+ # Step 2: build flat window list for batched AST inference
290
+ # windows[i] → chunk index window_owners[i]
291
+ windows: list[np.ndarray] = []
292
+ window_owners: list[int] = []
293
+ for chunk_idx, (samples, *_) in enumerate(file_results):
294
+ wins = (
295
+ [np.zeros(MAX_CHUNK_SAMPLES, dtype=np.float32)]
296
+ if samples is None
297
+ else _split_samples_into_windows(samples)
298
+ )
299
+ for w in wins:
300
+ windows.append(w)
301
+ window_owners.append(chunk_idx)
302
+
303
+ # Step 3: batched AST inference
304
+ win_music_scores: list[float] = []
305
+ win_top_labels: list[list[str]] = []
306
+
307
+ for i in range(0, len(windows), batch_size):
308
+ batch_wins = [w for w in windows[i:i + batch_size]]
309
+ inputs = fe(batch_wins, sampling_rate=SAMPLE_RATE, return_tensors="pt", padding=True)
310
+ inputs = {k: v.to(model.device) for k, v in inputs.items()}
311
+ with torch.no_grad():
312
+ logits = model(**inputs).logits
313
+ probs_batch = torch.sigmoid(logits).cpu().numpy() # (B, 527)
314
+
315
+ for row_probs in probs_batch:
316
+ win_music_scores.append(_music_score_from_probs(row_probs, music_indices))
317
+ win_top_labels.append(_top_labels_from_probs(row_probs, id2label))
318
+
319
+ # Step 4: max-pool windows → per-chunk score
320
+ chunk_music_scores = [0.0] * n
321
+ chunk_top_labels: list[list[str]] = [[] for _ in range(n)]
322
+ for win_idx, chunk_idx in enumerate(window_owners):
323
+ score = win_music_scores[win_idx]
324
+ if score > chunk_music_scores[chunk_idx]:
325
+ chunk_music_scores[chunk_idx] = score
326
+ chunk_top_labels[chunk_idx] = win_top_labels[win_idx]
327
+
328
+ # Step 5: assemble verdicts
329
+ verdicts: list[ChunkVerdict] = []
330
+ for chunk_idx in range(n):
331
+ samples, clipping, rms_db, max_silence, flags = file_results[chunk_idx]
332
+ music_score = chunk_music_scores[chunk_idx]
333
+ has_music = music_score >= music_threshold
334
+ contaminated = has_music or bool(flags)
335
+ verdicts.append(ChunkVerdict(
336
+ music_score=music_score,
337
+ has_music=has_music,
338
+ clipping_ratio=clipping,
339
+ rms_db=rms_db,
340
+ max_silence_sec=max_silence,
341
+ flags=flags,
342
+ top_labels=chunk_top_labels[chunk_idx],
343
+ contaminated=contaminated,
344
+ ))
345
+
346
+ return verdicts
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ torch>=2.0
3
+ transformers>=4.40
4
+ huggingface_hub>=0.24
5
+ hf-xet>=1.0.0
6
+ pyarrow>=15
7
+ numpy
8
+ soundfile