"""Chunk Classifier — HF Space.
Classifies audio chunks in one or more HF datasets using the AST AudioSet model.
Writes a ``_classified`` dataset with id + audio + classification columns only.
"""
from __future__ import annotations
import datetime
import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any, Generator
# Must be set before any HF import
os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "120")
import gradio as gr
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import requests
import torch
from huggingface_hub import HfApi, hf_hub_download
import music_detector
from music_detector import _get_ast_runtime, judge_chunk_files_batched
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
try:
N_CPUS = len(os.sched_getaffinity(0)) # container-aware (Linux cgroup)
except AttributeError:
N_CPUS = os.cpu_count() or 2 # macOS / Windows fallback
torch.set_num_threads(N_CPUS)
BATCH_SIZE = 32
DEFAULT_THRESHOLD = 0.25
DEFAULT_NAMESPACE = "fosters"
LOG_MAX_LINES = 600
DATASETS_SERVER = "https://datasets-server.huggingface.co"
_CLASSIFIER_FIELDS = [
pa.field("music_score", pa.float32()),
pa.field("has_music", pa.bool_()),
pa.field("contaminated", pa.bool_()),
pa.field("flags", pa.string()),
pa.field("top_labels", pa.string()),
pa.field("rms_db", pa.float32()),
pa.field("clipping_ratio", pa.float32()),
pa.field("max_silence_sec", pa.float32()),
]
_HF_FEATURES = {
"id": {"_type": "Value", "dtype": "string"},
"audio": {"_type": "Audio"},
"music_score": {"_type": "Value", "dtype": "float32"},
"has_music": {"_type": "Value", "dtype": "bool"},
"contaminated": {"_type": "Value", "dtype": "bool"},
"flags": {"_type": "Value", "dtype": "string"},
"top_labels": {"_type": "Value", "dtype": "string"},
"rms_db": {"_type": "Value", "dtype": "float32"},
"clipping_ratio": {"_type": "Value", "dtype": "float32"},
"max_silence_sec": {"_type": "Value", "dtype": "float32"},
}
_AUDIO_PA_TYPE = pa.struct([pa.field("bytes", pa.binary()), pa.field("path", pa.string())])
_OUTPUT_SCHEMA = pa.schema(
[
pa.field("id", pa.string()),
pa.field("audio", _AUDIO_PA_TYPE),
*_CLASSIFIER_FIELDS,
],
metadata={"huggingface": json.dumps({"info": {"features": _HF_FEATURES}})},
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ts() -> str:
return datetime.datetime.now().strftime("%H:%M:%S")
def _default_out_repo(in_repo: str, suffix: str) -> str:
parts = in_repo.split("/")
ns = parts[0] if len(parts) > 1 else DEFAULT_NAMESPACE
name = parts[-1]
suf = suffix.strip()
if not suf.startswith("_"):
suf = "_" + suf
return f"{ns}/{name}{suf}"
def _list_parquet_shards(repo_id: str, token: str | None) -> list[str]:
api = HfApi(token=token)
return sorted(
f.rfilename
for f in api.list_repo_tree(repo_id, repo_type="dataset", recursive=True)
if hasattr(f, "rfilename")
and f.rfilename.startswith("data/train-")
and f.rfilename.endswith(".parquet")
)
def _fetch_total_rows(repo_id: str, token: str | None) -> int | None:
"""Get total row count via datasets-server (no download required)."""
try:
headers = {"Authorization": f"Bearer {token}"} if token else {}
r = requests.get(
f"{DATASETS_SERVER}/info",
params={"dataset": repo_id, "config": "default"},
headers=headers,
timeout=20,
)
if r.ok:
splits = r.json().get("dataset_info", {}).get("default", {}).get("splits", {})
return splits.get("train", {}).get("num_examples")
except Exception:
pass
return None
def _rows_to_table(rows: list[dict[str, Any]]) -> pa.Table:
return pa.table(
{
"id": pa.array([r["id"] for r in rows], type=pa.string()),
"audio": pa.array(
[{"bytes": r["audio_bytes"], "path": r["audio_path"]} for r in rows],
type=_AUDIO_PA_TYPE,
),
"music_score": pa.array([r["music_score"] for r in rows], type=pa.float32()),
"has_music": pa.array([r["has_music"] for r in rows], type=pa.bool_()),
"contaminated": pa.array([r["contaminated"] for r in rows], type=pa.bool_()),
"flags": pa.array([r["flags"] for r in rows], type=pa.string()),
"top_labels": pa.array([r["top_labels"] for r in rows], type=pa.string()),
"rms_db": pa.array([r["rms_db"] for r in rows], type=pa.float32()),
"clipping_ratio": pa.array([r["clipping_ratio"] for r in rows], type=pa.float32()),
"max_silence_sec": pa.array([r["max_silence_sec"] for r in rows], type=pa.float32()),
},
schema=_OUTPUT_SCHEMA,
)
def _fmt_chunk(chunk_id: str, v: Any) -> str: # v: ChunkVerdict
if v.has_music:
icon = "🔴"
elif v.flags:
icon = "⚠️"
else:
icon = "✅"
labels = ", ".join(v.top_labels[:2])
flags = f" [{', '.join(v.flags)}]" if v.flags else ""
return f" {icon} {chunk_id} score={v.music_score:.3f} {labels}{flags}"
# ---------------------------------------------------------------------------
# Per-repo processing generator
# ---------------------------------------------------------------------------
# Yields: (new_log_lines: list[str], status: str, chunks_done: int, stats: dict | None)
# chunks_done counts chunks processed so far in this repo (for caller progress math)
def _process_repo(
repo_id: str,
out_repo: str,
music_threshold: float,
private: bool,
token: str | None,
) -> Generator[tuple[list[str], str, int, dict[str, Any] | None], None, None]:
api = HfApi(token=token)
short = repo_id.split("/")[-1]
# List shards
yield [f"[{_ts()}] {short}: listing shards…"], "Listing shards…", 0, None
try:
shard_names = _list_parquet_shards(repo_id, token)
except Exception as exc:
err = str(exc)
yield [f"[{_ts()}] {short}: ✗ {err}"], f"Error: {err}", 0, {"repo": repo_id, "error": err}
return
if not shard_names:
msg = "no data/train-*.parquet shards found"
yield [f"[{_ts()}] {short}: ✗ {msg}"], f"Error: {msg}", 0, {"repo": repo_id, "error": msg}
return
# Get total row count (for progress display, not critical)
total_rows = _fetch_total_rows(repo_id, token)
total_str = str(total_rows) if total_rows else "?"
yield [
f"[{_ts()}] {short}: {len(shard_names)} shard(s), {total_str} rows total"
], f"Preparing… {len(shard_names)} shards, {total_str} chunks", 0, None
api.create_repo(repo_id=out_repo, repo_type="dataset", exist_ok=True, private=private)
stats: dict[str, Any] = {
"repo": repo_id, "out_repo": out_repo,
"total": 0, "contaminated": 0, "has_music": 0, "technical": 0,
}
with tempfile.TemporaryDirectory() as tmp:
out_data_dir = Path(tmp) / "data"
out_data_dir.mkdir()
out_parts: list[Path] = []
for shard_idx, shard_name in enumerate(shard_names):
yield [
f"[{_ts()}] {short}: shard {shard_idx+1}/{len(shard_names)} — downloading…"
], f"[{shard_idx+1}/{len(shard_names)}] downloading shard…", stats["total"], None
t_dl = time.time()
try:
shard_path = hf_hub_download(
repo_id=repo_id, filename=shard_name,
repo_type="dataset", token=token,
)
except Exception as exc:
yield [f"[{_ts()}] {short}: ✗ download failed: {exc}"], "Download error", stats["total"], None
continue
in_table = pq.read_table(shard_path)
n_rows = len(in_table)
audio_col = in_table.column("audio")
id_col = in_table.column("id").to_pylist()
n_batches = (n_rows + BATCH_SIZE - 1) // BATCH_SIZE
yield [
f"[{_ts()}] {short}: shard {shard_idx+1}/{len(shard_names)} — "
f"{n_rows} rows, downloaded in {time.time()-t_dl:.1f}s"
], (
f"[{shard_idx+1}/{len(shard_names)}] {n_rows} rows — starting inference…"
), stats["total"], None
shard_rows: list[dict[str, Any]] = []
for batch_idx in range(n_batches):
b_start = batch_idx * BATCH_SIZE
b_end = min(b_start + BATCH_SIZE, n_rows)
t_batch = time.time()
batch_ids: list[str] = id_col[b_start:b_end]
batch_audio: list[dict] = [(audio_col[i].as_py() or {}) for i in range(b_start, b_end)]
batch_bytes: list[bytes] = [a.get("bytes") or b"" for a in batch_audio]
batch_paths: list[str] = [a.get("path") or "chunk.mp3" for a in batch_audio]
with tempfile.TemporaryDirectory() as audio_tmp:
files: list[Path] = []
for k, (ab, ph) in enumerate(zip(batch_bytes, batch_paths)):
p = Path(audio_tmp) / f"c{k:04d}{Path(ph).suffix or '.mp3'}"
p.write_bytes(ab)
files.append(p)
verdicts = judge_chunk_files_batched(
files,
music_threshold=music_threshold,
batch_size=len(files),
device="cpu",
)
for cid, ab, ph, v in zip(batch_ids, batch_bytes, batch_paths, verdicts):
shard_rows.append({
"id": cid, "audio_bytes": ab, "audio_path": ph,
"music_score": v.music_score,
"has_music": v.has_music,
"contaminated": v.contaminated,
"flags": ", ".join(v.flags),
"top_labels": ", ".join(v.top_labels[:3]),
"rms_db": v.rms_db,
"clipping_ratio": v.clipping_ratio,
"max_silence_sec": v.max_silence_sec,
})
n_cont = sum(v.contaminated for v in verdicts)
n_music = sum(v.has_music for v in verdicts)
n_tech = sum(bool(v.flags) and not v.has_music for v in verdicts)
stats["total"] += b_end - b_start
stats["contaminated"] += n_cont
stats["has_music"] += n_music
stats["technical"] += n_tech
elapsed = time.time() - t_batch
done = stats["total"]
cont_pct = 100 * stats["contaminated"] / max(done, 1)
tm = music_detector._LAST_TIMINGS
phase_str = (
f" · decode {tm.get('decode', 0):.1f}s "
f"/ fbank {tm.get('fbank', 0):.1f}s "
f"/ infer {tm.get('infer', 0):.1f}s"
if tm else ""
)
# Build log lines: one per chunk + batch summary
chunk_lines = [_fmt_chunk(cid, v) for cid, v in zip(batch_ids, verdicts)]
summary_line = (
f"[{_ts()}] batch {batch_idx+1}/{n_batches} "
f"· shard {shard_idx+1}/{len(shard_names)} "
f"· {elapsed:.1f}s"
f"{phase_str} "
f"· total {done}/{total_str} chunks "
f"· contaminated {stats['contaminated']} ({cont_pct:.1f}%)"
)
status = (
f"[{shard_idx+1}/{len(shard_names)}] "
f"batch {batch_idx+1}/{n_batches} "
f"· {done}/{total_str} chunks "
f"· {cont_pct:.1f}% contaminated"
)
yield [""] + chunk_lines + [summary_line], status, done, None
# Write shard
out_table = _rows_to_table(shard_rows)
out_part = out_data_dir / f"part_{shard_idx:05d}.parquet"
pq.write_table(out_table, out_part, row_group_size=1, compression="snappy")
out_parts.append(out_part)
yield [
f"[{_ts()}] {short}: shard {shard_idx+1} written "
f"({len(shard_rows)} rows, {out_part.stat().st_size // 1024} KB)"
], f"Shard {shard_idx+1} written, uploading…", stats["total"], None
# Rename + upload
n_shards = len(out_parts)
for i, p in enumerate(out_parts):
p.rename(out_data_dir / f"train-{i:05d}-of-{n_shards:05d}.parquet")
yield [f"[{_ts()}] {short}: uploading {n_shards} shard(s) → {out_repo}…"], "Uploading…", stats["total"], None
t_up = time.time()
api.upload_folder(
folder_path=str(out_data_dir),
repo_id=out_repo,
repo_type="dataset",
path_in_repo="data/",
delete_patterns=["data/*.parquet"],
commit_message=(
f"Classify {stats['total']} chunks "
f"(threshold={music_threshold:.2f}, "
f"contaminated={stats['contaminated']})"
),
)
url = f"https://huggingface.co/datasets/{out_repo}"
yield [
f"[{_ts()}] {short}: ✓ uploaded in {time.time()-t_up:.1f}s → {url}"
], f"Done ✓ → {out_repo}", stats["total"], stats
# ---------------------------------------------------------------------------
# Gradio generator (no gr.Progress — avoids overlay)
# ---------------------------------------------------------------------------
# Outputs: log_text, status_text, summary_df, links_md
def classify_repos(
repos_text: str,
out_suffix: str,
music_threshold: float,
private: bool,
hf_token_input: str,
) -> Generator[tuple[str, str, pd.DataFrame, str], None, None]:
token = hf_token_input.strip() or os.environ.get("HF_TOKEN") or None
repos = [
r.strip()
for line in repos_text.replace(",", "\n").splitlines()
for r in [line.strip()]
if r and "/" in r
]
if not repos:
yield "No valid repo IDs (expected owner/name format).", "", pd.DataFrame(), ""
return
log: list[str] = []
rows: list[dict[str, Any]] = []
links: list[str] = []
def _snapshot(status: str) -> tuple[str, str, pd.DataFrame, str]:
log_text = "\n".join(log[-LOG_MAX_LINES:])
df = pd.DataFrame(rows) if rows else pd.DataFrame()
links_md = "## Output datasets\n" + "\n".join(links) if links else ""
return log_text, status, df, links_md
log.append(f"[{_ts()}] {len(repos)} repo(s) · threshold={music_threshold:.2f} · CPUs={N_CPUS}")
log.append(f"[{_ts()}] HF_XET_HIGH_PERFORMANCE={os.environ.get('HF_XET_HIGH_PERFORMANCE','off')}")
log.append(f"[{_ts()}] Loading AST model…")
yield _snapshot("Loading AST model…")
try:
_get_ast_runtime("cpu")
log.append(f"[{_ts()}] Model ready ✓")
except Exception as exc:
log.append(f"[{_ts()}] ✗ model load failed: {exc}")
yield _snapshot(f"Error: {exc}")
return
yield _snapshot("Model ready")
t_total = time.time()
for repo_idx, repo_id in enumerate(repos):
out_repo = _default_out_repo(repo_id, out_suffix)
log.append(f"\n[{_ts()}] {'─'*56}")
log.append(f"[{_ts()}] [{repo_idx+1}/{len(repos)}] {repo_id} → {out_repo}")
t_repo = time.time()
final_stats: dict[str, Any] | None = None
for new_lines, status, chunks_done, maybe_stats in _process_repo(
repo_id=repo_id,
out_repo=out_repo,
music_threshold=music_threshold,
private=private,
token=token,
):
log.extend(new_lines)
if maybe_stats is not None:
final_stats = maybe_stats
yield _snapshot(f"[{repo_idx+1}/{len(repos)}] {status}")
# Summary row
if final_stats and "error" not in final_stats:
n = final_stats["total"]
cont = final_stats["contaminated"]
rows.append({
"repo": repo_id.split("/")[-1],
"chunks": n,
"contam_%": f"{100*cont/max(n,1):.1f}",
"music": final_stats["has_music"],
"technical": final_stats["technical"],
"time_s": f"{time.time()-t_repo:.0f}",
"output": out_repo,
})
links.append(f"- [{out_repo}](https://huggingface.co/datasets/{out_repo})")
log.append(
f"[{_ts()}] DONE total={n} contaminated={cont} ({100*cont/max(n,1):.1f}%) "
f"music={final_stats['has_music']} technical={final_stats['technical']} "
f"elapsed={time.time()-t_repo:.0f}s"
)
elif final_stats:
rows.append({
"repo": repo_id.split("/")[-1], "chunks": 0,
"contam_%": "—", "music": 0, "technical": 0,
"time_s": "—", "output": f"ERROR: {final_stats.get('error')}",
})
yield _snapshot(f"[{repo_idx+1}/{len(repos)}] done")
log.append(f"\n[{_ts()}] {'='*56}")
log.append(f"[{_ts()}] All done in {time.time()-t_total:.0f}s")
yield _snapshot("All done ✓")
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
DESCRIPTION = """
# 🎵 Chunk Classifier
Scores audio chunks for **music contamination** and **technical defects** using
[MIT/ast-finetuned-audioset-10-10-0.4593](https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593)
(527 AudioSet classes, multi-label sigmoid).
**Input** — chunk dataset repo IDs (one per line).
**Output** — `_classified` with `id · audio · music_score · has_music · contaminated · flags · top_labels · rms_db · clipping_ratio · max_silence_sec`.
Sort by `music_score ↓` in the HF viewer and press play to spot-check.
"""
with gr.Blocks(title="Chunk Classifier") as demo:
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column(scale=2):
repos_input = gr.Textbox(
label="Dataset repo IDs (one per line)",
placeholder="fosters/my-book-chunks\nfosters/another-book-chunks",
lines=5,
)
with gr.Column(scale=1):
out_suffix = gr.Textbox(
label="Output suffix", value="_classified",
info="Appended to each input repo name",
)
threshold = gr.Slider(
0.10, 0.60, value=DEFAULT_THRESHOLD, step=0.05,
label="Music threshold",
info="Lower = more sensitive (recall-biased). Default 0.25.",
)
private_toggle = gr.Checkbox(label="Private output", value=True)
token_input = gr.Textbox(
label="HF Token (or set HF_TOKEN secret)",
type="password", placeholder="hf_…",
)
run_btn = gr.Button("▶ Classify", variant="primary", size="lg")
# Status line — replaces gr.Progress() which caused overlay issues
status_out = gr.Textbox(
label="Status",
interactive=False,
lines=1,
max_lines=1,
)
with gr.Row():
with gr.Column(scale=3):
log_out = gr.Textbox(
label="Processing log (updates every batch ~30s)",
interactive=False,
lines=28,
max_lines=60,
)
with gr.Column(scale=2):
summary_out = gr.Dataframe(
label="Summary",
headers=["repo", "chunks", "contam_%", "music", "technical", "time_s", "output"],
wrap=True,
)
links_out = gr.Markdown()
run_btn.click(
classify_repos,
inputs=[repos_input, out_suffix, threshold, private_toggle, token_input],
outputs=[log_out, status_out, summary_out, links_out],
)
demo.queue()
demo.launch(server_name="0.0.0.0")